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