diff --git a/backend/.env.example b/backend/.env.example index c0660ea..08eb115 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -47,14 +47,14 @@ REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 -MAIL_MAILER=log +MAIL_MAILER=smtp MAIL_SCHEME=null MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 +MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" +MAIL_FROM_ADDRESS="noreply@tide.test" +MAIL_FROM_NAME="TIDE" AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/backend/app/Console/Commands/UserPromoteCommand.php b/backend/app/Console/Commands/UserPromoteCommand.php new file mode 100644 index 0000000..9c6e0a6 --- /dev/null +++ b/backend/app/Console/Commands/UserPromoteCommand.php @@ -0,0 +1,59 @@ +argument('email'); + if (! is_string($rawEmail) || $rawEmail === '') { + $this->error('email is required'); + + return self::FAILURE; + } + + try { + $email = new EmailAddress($rawEmail); + } catch (InvalidArgumentException $exception) { + $this->error($exception->getMessage()); + + return self::FAILURE; + } + + $user = $userRepo->findByEmail($email); + if ($user === null) { + $this->error("user not found: {$rawEmail}"); + + return self::FAILURE; + } + + if ($user->isAdmin()) { + $this->info("{$rawEmail} is already an admin"); + + return self::SUCCESS; + } + + $userRepo->update(new User( + id: $user->getId(), + email: $user->getEmail(), + displayName: $user->getDisplayName(), + passwordHash: $user->getPasswordHash(), + isAdmin: true, + emailConfirmedAt: $user->getEmailConfirmedAt(), + )); + $this->info("{$rawEmail} is now an admin"); + + return self::SUCCESS; + } +} diff --git a/flake.nix b/flake.nix index fb42a49..c8fed26 100644 --- a/flake.nix +++ b/flake.nix @@ -24,6 +24,7 @@ typescript postgresql process-compose + mailpit ]; shellHook = '' diff --git a/frontend/blog_portal/cypress.config.ts b/frontend/blog_portal/cypress.config.ts index 9f9987c..5f5c1df 100644 --- a/frontend/blog_portal/cypress.config.ts +++ b/frontend/blog_portal/cypress.config.ts @@ -3,6 +3,7 @@ import { execSync } from "node:child_process"; import { resolve } from "node:path"; const backendDir = resolve(__dirname, "../../backend"); +const mailpitBase = "http://localhost:8025/api/v1"; export default defineConfig({ e2e: { @@ -23,6 +24,25 @@ export default defineConfig({ }); return null; }, + "db:promote": function (email: string) { + execSync(`php artisan user:promote ${JSON.stringify(email)}`, { + cwd: backendDir, + stdio: "pipe", + }); + return null; + }, + "mailpit:clear": async function () { + await fetch(`${mailpitBase}/messages`, { method: "DELETE" }); + return null; + }, + "mailpit:messages": async function () { + const response = await fetch(`${mailpitBase}/messages`); + return response.json(); + }, + "mailpit:message": async function (id: string) { + const response = await fetch(`${mailpitBase}/message/${id}`); + return response.json(); + }, }); }, }, diff --git a/frontend/blog_portal/cypress/e2e/admin_actions.cy.ts b/frontend/blog_portal/cypress/e2e/admin_actions.cy.ts new file mode 100644 index 0000000..7412b2e --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/admin_actions.cy.ts @@ -0,0 +1,99 @@ +describe("admin actions", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedAdmin({ + email: "admin@example.com", + displayName: "rootadmin", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "bob@example.com", + displayName: "bob", + password: "longenoughpassword", + }); + cy.seedPostAs({ + email: "alice@example.com", + password: "longenoughpassword", + title: "Alice post", + body: "Body.", + }).as("alicePost"); + }); + + it("admin promotes another user via the profile page", function () { + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice"); + cy.contains("button", "Promote to admin").click(); + + cy.logoutViaApi(); + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Feature in slot 1").should("be.visible"); + }); + }); + + it("admin features a post and it shows up on the home page", function () { + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Feature in slot 1").click(); + cy.contains(".badge", "Featured (slot 1)").should("be.visible"); + }); + + cy.logoutViaApi(); + cy.visit("/"); + cy.contains("h2", "Featured").should("be.visible"); + cy.get(".featured .post-card").should("have.length", 1); + cy.contains(".featured .post-card", "Alice post").should("be.visible"); + }); + + it("admin unfeaturing a post removes it from the home page", function () { + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Feature in slot 1").click(); + cy.contains(".badge", "Featured (slot 1)").should("be.visible"); + cy.contains("button", "Unfeature").click(); + cy.contains(".badge", "Featured").should("not.exist"); + }); + + cy.logoutViaApi(); + cy.visit("/"); + cy.contains("h2", "Featured").should("not.exist"); + }); + + it("a newly promoted admin can also feature posts", function () { + cy.promoteAdmin("bob@example.com"); + cy.loginViaApi({ + email: "bob@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Feature in slot 2").click(); + cy.contains(".badge", "Featured (slot 2)").should("be.visible"); + }); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/confirm_email_page.cy.ts b/frontend/blog_portal/cypress/e2e/confirm_email_page.cy.ts new file mode 100644 index 0000000..09415f5 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/confirm_email_page.cy.ts @@ -0,0 +1,48 @@ +describe("confirm email page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + }); + + it("redirects to /login when no token is present", function () { + cy.visit("/confirm-email"); + cy.location("pathname").should("eq", "/login"); + }); + + it("sets the password and redirects to /login on success", function () { + cy.signupViaApi({ + email: "alice@example.com", + displayName: "alice", + }); + cy.fetchLatestConfirmToken("alice@example.com").then(function (token) { + cy.visit(`/confirm-email?token=${token}`); + cy.get('input[type="password"]').type("longenoughpassword"); + cy.contains("button", "Confirm").click(); + + cy.location("pathname").should("eq", "/login"); + + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + }); + }); + + it("shows the backend error when password is too short", function () { + cy.signupViaApi({ + email: "alice@example.com", + displayName: "alice", + }); + cy.fetchLatestConfirmToken("alice@example.com").then(function (token) { + cy.visit(`/confirm-email?token=${token}`); + cy.get('input[type="password"]') + .invoke("removeAttr", "minlength") + .type("short"); + cy.contains("button", "Confirm").click(); + + cy.contains(".error", "password must be at least 8").should( + "be.visible", + ); + }); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/guest_routes.cy.ts b/frontend/blog_portal/cypress/e2e/guest_routes.cy.ts new file mode 100644 index 0000000..2f06222 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/guest_routes.cy.ts @@ -0,0 +1,41 @@ +describe("guest route guards", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + }); + + it("redirects anonymous visitors away from /users/:displayName/posts/new", function () { + cy.visit("/users/alice/posts/new"); + cy.location("pathname").should("eq", "/login"); + }); + + it("redirects authenticated visitors away from /login", function () { + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/login"); + cy.location("pathname").should("eq", "/"); + }); + + it("redirects authenticated visitors away from /signup", function () { + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/signup"); + cy.location("pathname").should("eq", "/"); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/home_page.cy.ts b/frontend/blog_portal/cypress/e2e/home_page.cy.ts new file mode 100644 index 0000000..17667c9 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/home_page.cy.ts @@ -0,0 +1,101 @@ +describe("home page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "bob@example.com", + displayName: "bob", + password: "longenoughpassword", + }); + cy.seedPostAs({ + email: "alice@example.com", + password: "longenoughpassword", + title: "Alice first", + body: "Alice's first post.", + }); + cy.seedPostAs({ + email: "bob@example.com", + password: "longenoughpassword", + title: "Bob says hi", + body: "Bob's only post.", + }); + cy.seedPostAs({ + email: "alice@example.com", + password: "longenoughpassword", + title: "Alice second", + body: "Alice's second post.", + }); + }); + + it("renders recent posts in newest-first order with author links", function () { + cy.visit("/"); + cy.contains("h2", "Recent posts").should("be.visible"); + cy.get(".recent .post-card").should("have.length", 3); + cy.get(".recent .post-card").first().within(function () { + cy.contains("Alice second"); + cy.contains("a", "alice"); + }); + }); + + it("hides the featured section when no posts are featured", function () { + cy.visit("/"); + cy.contains("h2", "Featured").should("not.exist"); + }); + + it("shows featured posts in slot order once an admin features them", function () { + cy.seedAdmin({ + email: "admin@example.com", + displayName: "rootadmin", + password: "longenoughpassword", + }); + cy.request("/api/posts").then(function (response) { + const posts = response.body.posts as Array<{ + id: number; + title: string; + }>; + const aliceSecond = posts.find(function (entry) { + return entry.title === "Alice second"; + })!; + const bobPost = posts.find(function (entry) { + return entry.title === "Bob says hi"; + })!; + cy.seedFeaturedPost({ + adminEmail: "admin@example.com", + adminPassword: "longenoughpassword", + postId: bobPost.id, + slot: 2, + }); + cy.seedFeaturedPost({ + adminEmail: "admin@example.com", + adminPassword: "longenoughpassword", + postId: aliceSecond.id, + slot: 1, + }); + }); + + cy.visit("/"); + cy.contains("h2", "Featured").should("be.visible"); + cy.get(".featured .post-card").should("have.length", 2); + cy.get(".featured .post-card").eq(0).contains("Alice second"); + cy.get(".featured .post-card").eq(1).contains("Bob says hi"); + }); + + it("searches users by display name and email prefix", function () { + cy.visit("/"); + cy.get('input[type="search"]').type("al"); + cy.get(".results li").should("have.length", 1); + cy.get(".results li").contains("alice"); + + cy.get('input[type="search"]').clear().type("b"); + cy.get(".results li").should("have.length", 1); + cy.get(".results li").contains("bob"); + + cy.get('input[type="search"]').clear().type("zzz"); + cy.get(".results li").should("have.length", 0); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/login_page.cy.ts b/frontend/blog_portal/cypress/e2e/login_page.cy.ts new file mode 100644 index 0000000..56b7370 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/login_page.cy.ts @@ -0,0 +1,41 @@ +describe("login page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + }); + + it("logs in with valid credentials and shows the user in the header", function () { + cy.visit("/login"); + cy.get('input[type="email"]').type("alice@example.com"); + cy.get('input[type="password"]').type("longenoughpassword"); + cy.contains("button", "Log in").click(); + + cy.location("pathname").should("eq", "/"); + cy.get(".app-header").contains("alice").should("be.visible"); + }); + + it("shows an error on wrong password", function () { + cy.visit("/login"); + cy.get('input[type="email"]').type("alice@example.com"); + cy.get('input[type="password"]').type("wrongpassword"); + cy.contains("button", "Log in").click(); + + cy.location("pathname").should("eq", "/login"); + cy.contains(".error", "invalid credentials").should("be.visible"); + }); + + it("redirects authenticated users away from /login", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/login"); + cy.location("pathname").should("eq", "/"); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/new_post_page.cy.ts b/frontend/blog_portal/cypress/e2e/new_post_page.cy.ts new file mode 100644 index 0000000..db9673b --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/new_post_page.cy.ts @@ -0,0 +1,63 @@ +describe("new post page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "bob@example.com", + displayName: "bob", + password: "longenoughpassword", + }); + }); + + it("redirects anonymous visitors to /login", function () { + cy.visit("/users/alice/posts/new"); + cy.location("pathname").should("eq", "/login"); + }); + + it("redirects users away from another user's new-post page", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/bob/posts/new"); + cy.location("pathname").should("eq", "/"); + }); + + it("publishes a post and redirects to the new post page", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice/posts/new"); + cy.get('input[type="text"]').type("Hello world"); + cy.get("textarea").type("This is the body of my first post."); + cy.contains("button", "Publish").click(); + + cy.location("pathname").should("match", /^\/posts\/\d+$/); + cy.contains("h1", "Hello world").should("be.visible"); + cy.contains(".body", "This is the body of my first post.").should( + "be.visible", + ); + }); + + it("blocks submission when the title is empty (HTML5 validation)", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice/posts/new"); + cy.get("textarea").type("body only"); + cy.contains("button", "Publish").click(); + + cy.location("pathname").should("eq", "/users/alice/posts/new"); + cy.get('input[type="text"]:invalid').should("exist"); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/post_page.cy.ts b/frontend/blog_portal/cypress/e2e/post_page.cy.ts new file mode 100644 index 0000000..4bf4a8c --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/post_page.cy.ts @@ -0,0 +1,136 @@ +describe("post page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "bob@example.com", + displayName: "bob", + password: "longenoughpassword", + }); + cy.seedPostAs({ + email: "alice@example.com", + password: "longenoughpassword", + title: "Alice post", + body: "Body of alice's post.", + }).as("alicePost"); + }); + + it("renders the post for anonymous visitors with a login CTA", function () { + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("h1", "Alice post").should("be.visible"); + cy.contains("a", "alice").should("be.visible"); + cy.contains("Comments (0)").should("be.visible"); + cy.contains("Log in").should("be.visible"); + cy.get(".comment-form").should("not.exist"); + }); + }); + + it("lets a signed-in user add a comment", function () { + cy.loginViaApi({ + email: "bob@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.get(".comment-form textarea").type("nice post"); + cy.contains(".comment-form button", "Comment").click(); + + cy.contains(".comment-list li", "nice post").should("be.visible"); + cy.contains(".comment-list li", "bob").should("be.visible"); + cy.contains("Comments (1)").should("be.visible"); + }); + }); + + it("only shows the Delete control on comments you can delete", function () { + cy.loginViaApi({ + email: "bob@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.request("POST", `/api/posts/${post.id}/comments`, { + body: "bob's comment", + }); + cy.logoutViaApi(); + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + cy.request("POST", `/api/posts/${post.id}/comments`, { + body: "alice's comment", + }); + + cy.visit(`/posts/${post.id}`); + cy.contains(".comment-list li", "alice's comment") + .find("button.delete") + .should("exist"); + cy.contains(".comment-list li", "bob's comment") + .find("button.delete") + .should("not.exist"); + }); + }); + + it("hides the Delete post control for non-author non-admin users", function () { + cy.loginViaApi({ + email: "bob@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Delete post").should("not.exist"); + cy.contains("button", "Feature in slot 1").should("not.exist"); + }); + }); + + it("lets the author delete their post", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Delete post").click(); + cy.location("pathname").should("eq", "/"); + }); + }); + + it("shows admin controls (delete, feature, unfeature) to admins", function () { + cy.seedAdmin({ + email: "admin@example.com", + displayName: "rootadmin", + password: "longenoughpassword", + }); + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.get<{ id: number }>("@alicePost").then(function (post) { + cy.visit(`/posts/${post.id}`); + cy.contains("button", "Delete post").should("be.visible"); + cy.contains("button", "Feature in slot 1").should("be.visible"); + cy.contains("button", "Feature in slot 2").should("be.visible"); + + cy.contains("button", "Feature in slot 1").click(); + cy.contains(".badge", "Featured (slot 1)").should("be.visible"); + cy.contains("button", "Unfeature").should("be.visible"); + + cy.contains("button", "Unfeature").click(); + cy.contains(".badge", "Featured").should("not.exist"); + }); + }); + + it("shows a not-found panel for unknown post ids", function () { + cy.visit("/posts/9999"); + cy.contains("h1", "Post not found").should("be.visible"); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/profile_page.cy.ts b/frontend/blog_portal/cypress/e2e/profile_page.cy.ts new file mode 100644 index 0000000..375e3c9 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/profile_page.cy.ts @@ -0,0 +1,86 @@ +describe("profile page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.seedConfirmedUser({ + email: "bob@example.com", + displayName: "bob", + password: "longenoughpassword", + }); + cy.seedPostAs({ + email: "alice@example.com", + password: "longenoughpassword", + title: "Alice's only post", + body: "Hello.", + }); + }); + + it("shows posts to anonymous visitors without owner controls", function () { + cy.visit("/users/alice"); + cy.contains("h1", "alice").should("be.visible"); + cy.contains(".post-card", "Alice's only post").should("be.visible"); + cy.contains("button", "New post").should("not.exist"); + cy.contains("button", "Promote to admin").should("not.exist"); + }); + + it("shows the New post button when viewing your own profile", function () { + cy.loginViaApi({ + email: "alice@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice"); + cy.contains("button", "New post").should("be.visible"); + cy.contains("button", "Promote to admin").should("not.exist"); + }); + + it("hides the Promote button from non-admin viewers", function () { + cy.loginViaApi({ + email: "bob@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice"); + cy.contains("button", "Promote to admin").should("not.exist"); + }); + + it("shows the Promote button to an admin viewing someone else", function () { + cy.seedAdmin({ + email: "admin@example.com", + displayName: "rootadmin", + password: "longenoughpassword", + }); + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/alice"); + cy.contains("button", "Promote to admin").should("be.visible"); + }); + + it("hides the Promote button when an admin views their own profile", function () { + cy.seedAdmin({ + email: "admin@example.com", + displayName: "rootadmin", + password: "longenoughpassword", + }); + cy.loginViaApi({ + email: "admin@example.com", + password: "longenoughpassword", + }); + + cy.visit("/users/rootadmin"); + cy.contains("button", "Promote to admin").should("not.exist"); + }); + + it("renders a not-found panel for unknown display names", function () { + cy.visit("/users/nobody"); + cy.contains("h1", "User not found").should("be.visible"); + }); +}); diff --git a/frontend/blog_portal/cypress/e2e/signup_page.cy.ts b/frontend/blog_portal/cypress/e2e/signup_page.cy.ts new file mode 100644 index 0000000..0752e66 --- /dev/null +++ b/frontend/blog_portal/cypress/e2e/signup_page.cy.ts @@ -0,0 +1,67 @@ +describe("signup page", function () { + beforeEach(function () { + cy.resetDb(); + cy.clearMail(); + }); + + it("creates an unconfirmed user and redirects to check-email", function () { + cy.visit("/signup"); + cy.get('input[type="email"]').type("alice@example.com"); + cy.get('input[autocomplete="username"]').type("alice"); + cy.contains("button", "Continue").click(); + + cy.location("pathname").should("eq", "/check-email"); + cy.contains("h1", "Check your email").should("be.visible"); + + cy.getMail().then(function (inbox) { + expect(inbox.messages).to.have.length(1); + expect(inbox.messages[0].To[0].Address).to.equal( + "alice@example.com", + ); + }); + }); + + it("surfaces a 409 when the email is already registered", function () { + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.clearMail(); + + cy.visit("/signup"); + cy.get('input[type="email"]').type("alice@example.com"); + cy.get('input[autocomplete="username"]').type("alice2"); + cy.contains("button", "Continue").click(); + + cy.location("pathname").should("eq", "/signup"); + cy.contains(".error", "email already registered").should("be.visible"); + }); + + it("surfaces a 409 when the display name is taken", function () { + cy.seedConfirmedUser({ + email: "alice@example.com", + displayName: "alice", + password: "longenoughpassword", + }); + cy.clearMail(); + + cy.visit("/signup"); + cy.get('input[type="email"]').type("other@example.com"); + cy.get('input[autocomplete="username"]').type("alice"); + cy.contains("button", "Continue").click(); + + cy.location("pathname").should("eq", "/signup"); + cy.contains(".error", "displayName already taken").should("be.visible"); + }); + + it("blocks invalid display name characters client-side", function () { + cy.visit("/signup"); + cy.get('input[type="email"]').type("alice@example.com"); + cy.get('input[autocomplete="username"]').type("Has Spaces"); + cy.contains("button", "Continue").click(); + + cy.location("pathname").should("eq", "/signup"); + cy.get('input[autocomplete="username"]:invalid').should("exist"); + }); +}); diff --git a/frontend/blog_portal/cypress/support/commands.ts b/frontend/blog_portal/cypress/support/commands.ts index a25fbf7..526f445 100644 --- a/frontend/blog_portal/cypress/support/commands.ts +++ b/frontend/blog_portal/cypress/support/commands.ts @@ -1,15 +1,104 @@ /// +interface MailpitAddress { + Address: string; + Name: string; +} + +interface MailpitMessageSummary { + ID: string; + From: MailpitAddress; + To: MailpitAddress[]; + Subject: string; + Snippet: string; + Created: string; +} + +interface MailpitMessages { + total: number; + unread: number; + count: number; + messages: MailpitMessageSummary[]; +} + +interface MailpitMessageBody { + ID: string; + Text: string; + HTML: string; +} + +interface SignupArgs { + email: string; + displayName: string; +} + +interface ConfirmArgs { + token: string; + password: string; +} + +interface LoginArgs { + email: string; + password: string; +} + +interface SeedConfirmedUserArgs { + email: string; + displayName: string; + password: string; +} + +type SeedAdminArgs = SeedConfirmedUserArgs; + +interface SeedPostArgs { + email: string; + password: string; + title: string; + body: string; +} + +interface CreatedPost { + id: number; + userId: number; + authorDisplayName: string; + title: string; + body: string; + createdAt: string; + featureSlot: number | null; +} + +interface SeedFeaturedPostArgs { + adminEmail: string; + adminPassword: string; + postId: number; + slot: number; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { resetDb(): Chainable; seedDb(): Chainable; + promoteAdmin(email: string): Chainable; + clearMail(): Chainable; + getMail(): Chainable; + getMailBody(id: string): Chainable; + signupViaApi(args: SignupArgs): Chainable; + fetchLatestConfirmToken(email: string): Chainable; + confirmViaApi(args: ConfirmArgs): Chainable; + loginViaApi(args: LoginArgs): Chainable; + logoutViaApi(): Chainable; + seedConfirmedUser(args: SeedConfirmedUserArgs): Chainable; + seedAdmin(args: SeedAdminArgs): Chainable; + seedPostAs(args: SeedPostArgs): Chainable; + seedFeaturedPost(args: SeedFeaturedPostArgs): Chainable; } } } +const apiBase = "/api"; + Cypress.Commands.add("resetDb", function () { return cy.task("db:reset"); }); @@ -18,4 +107,129 @@ Cypress.Commands.add("seedDb", function () { return cy.task("db:seed"); }); +Cypress.Commands.add("promoteAdmin", function (email: string) { + return cy.task("db:promote", email); +}); + +Cypress.Commands.add("clearMail", function () { + return cy.task("mailpit:clear"); +}); + +Cypress.Commands.add("getMail", function () { + return cy.task("mailpit:messages"); +}); + +Cypress.Commands.add("getMailBody", function (id: string) { + return cy.task("mailpit:message", id); +}); + +Cypress.Commands.add("signupViaApi", function (args: SignupArgs) { + cy.request({ + method: "POST", + url: `${apiBase}/signup`, + body: { email: args.email, displayName: args.displayName }, + }).then(function (response) { + expect(response.status).to.equal(201); + }); +}); + +Cypress.Commands.add("fetchLatestConfirmToken", function (email: string) { + return cy.getMail().then(function (inbox) { + const match = inbox.messages.find(function (message) { + return message.To.some(function (to) { + return to.Address.toLowerCase() === email.toLowerCase(); + }); + }); + if (!match) { + throw new Error(`no mailpit message for ${email}`); + } + return cy.getMailBody(match.ID).then(function (body) { + const text = body.Text || body.HTML; + const tokenMatch = text.match(/token=([a-f0-9]+)/); + if (!tokenMatch) { + throw new Error("confirmation token not found in mail body"); + } + return tokenMatch[1]; + }); + }); +}); + +Cypress.Commands.add("confirmViaApi", function (args: ConfirmArgs) { + cy.request({ + method: "POST", + url: `${apiBase}/confirm-email`, + body: { token: args.token, password: args.password }, + }).then(function (response) { + expect(response.status).to.equal(200); + }); +}); + +Cypress.Commands.add("loginViaApi", function (args: LoginArgs) { + cy.request({ + method: "POST", + url: `${apiBase}/login`, + body: { email: args.email, password: args.password }, + }).then(function (response) { + expect(response.status).to.equal(200); + }); +}); + +Cypress.Commands.add("logoutViaApi", function () { + cy.request({ + method: "POST", + url: `${apiBase}/logout`, + failOnStatusCode: false, + }); +}); + +Cypress.Commands.add( + "seedConfirmedUser", + function (args: SeedConfirmedUserArgs) { + cy.signupViaApi({ email: args.email, displayName: args.displayName }); + cy.fetchLatestConfirmToken(args.email).then(function (token) { + cy.confirmViaApi({ token, password: args.password }); + }); + cy.logoutViaApi(); + }, +); + +Cypress.Commands.add("seedAdmin", function (args: SeedAdminArgs) { + cy.seedConfirmedUser({ + email: args.email, + displayName: args.displayName, + password: args.password, + }); + cy.promoteAdmin(args.email); +}); + +Cypress.Commands.add("seedPostAs", function (args: SeedPostArgs) { + cy.loginViaApi({ email: args.email, password: args.password }); + return cy + .request({ + method: "POST", + url: `${apiBase}/posts`, + body: { title: args.title, body: args.body }, + }) + .then(function (response) { + expect(response.status).to.equal(201); + cy.logoutViaApi(); + return response.body.post as CreatedPost; + }); +}); + +Cypress.Commands.add( + "seedFeaturedPost", + function (args: SeedFeaturedPostArgs) { + cy.loginViaApi({ email: args.adminEmail, password: args.adminPassword }); + cy.request({ + method: "POST", + url: `${apiBase}/admin/posts/feature`, + body: { postId: args.postId, slot: args.slot }, + }).then(function (response) { + expect(response.status).to.equal(200); + }); + cy.logoutViaApi(); + }, +); + export {}; diff --git a/process-compose.yaml b/process-compose.yaml index f4b785f..872fa59 100644 --- a/process-compose.yaml +++ b/process-compose.yaml @@ -11,12 +11,24 @@ processes: initial_delay_seconds: 1 period_seconds: 2 + mailpit: + command: mailpit --smtp 127.0.0.1:1025 --listen 127.0.0.1:8025 + readiness_probe: + http_get: + host: 127.0.0.1 + port: 8025 + path: / + initial_delay_seconds: 1 + period_seconds: 2 + backend: command: php artisan serve --host=127.0.0.1 --port=8000 working_dir: ./backend depends_on: postgres: condition: process_healthy + mailpit: + condition: process_healthy readiness_probe: http_get: host: 127.0.0.1