Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,13 @@ MAX_FILE_SIZE=52428800
# ===========================================
# Log level (debug, info, warn, error)
LOG_LEVEL=debug

# ===========================================
# Email Configuration (Resend)
# ===========================================
# Resend API key (optional - emails are skipped if not set)
RESEND_API_KEY=
# From email address for transactional emails
RESEND_FROM_EMAIL=noreply@example.com
# Application URL (used in email links)
APP_URL=http://localhost:3000
73 changes: 73 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"resend": "^6.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
-- CreateEnum
CREATE TYPE "RoleRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED');

-- AlterTable: Change default role from DSL_DESIGNER to VIEWER
ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'VIEWER';

-- CreateTable
CREATE TABLE "password_reset_tokens" (
"id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "role_requests" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"currentRole" "UserRole" NOT NULL,
"requestedRole" "UserRole" NOT NULL,
"reason" TEXT NOT NULL,
"status" "RoleRequestStatus" NOT NULL DEFAULT 'PENDING',
"reviewedById" TEXT,
"reviewNote" TEXT,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "role_requests_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash");

-- AddForeignKey
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "role_requests" ADD CONSTRAINT "role_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "role_requests" ADD CONSTRAINT "role_requests_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
51 changes: 49 additions & 2 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ enum ResourceType {
TEST_CASE
}

enum RoleRequestStatus {
PENDING
APPROVED
REJECTED
}

enum SharePermission {
VIEWER
EDITOR
Expand All @@ -43,7 +49,7 @@ model User {
id String @id @default(uuid())
email String @unique
password String // Hashed with bcrypt
role UserRole @default(DSL_DESIGNER)
role UserRole @default(VIEWER)
isSuspended Boolean @default(false)
lastLogin DateTime?

Expand All @@ -63,7 +69,12 @@ model User {
// Sharing relations
ownedShares SharedResource[] @relation("ShareOwner")
receivedShares SharedResource[] @relation("ShareRecipient")


// Password reset & role request relations
passwordResetTokens PasswordResetToken[]
roleRequests RoleRequest[]
reviewedRoleRequests RoleRequest[] @relation("RoleRequestReviewer")

@@map("users")
}

Expand Down Expand Up @@ -373,3 +384,39 @@ enum FileType {
model
other
}

// ===========================================
// Password Reset Tokens
// ===========================================

model PasswordResetToken {
id String @id @default(uuid())
tokenHash String @unique @map("token_hash")
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
@@map("password_reset_tokens")
Comment on lines +392 to +400
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you move to hashed password reset tokens (recommended), the schema will need to store the hash rather than the raw token. As written, token is a unique plaintext value; that couples the DB to the insecure approach and makes it hard to rotate to hashing later without migration.

Copilot uses AI. Check for mistakes.
}

// ===========================================
// Role Requests
// ===========================================

model RoleRequest {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
currentRole UserRole
requestedRole UserRole
reason String
status RoleRequestStatus @default(PENDING)
reviewedById String?
reviewedBy User? @relation("RoleRequestReviewer", fields: [reviewedById], references: [id])
reviewNote String?
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("role_requests")
}
109 changes: 108 additions & 1 deletion backend/src/__tests__/services/auth.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ jest.mock('../../services/metametamodel.service', () => ({
},
}));

// Mock email service
jest.mock('../../services/email.service', () => ({
sendWelcomeEmail: jest.fn(),
sendPasswordResetEmail: jest.fn(),
}));

beforeEach(() => {
mockReset(prismaMock);
jest.clearAllMocks();
Expand Down Expand Up @@ -56,7 +62,7 @@ describe('AuthService', () => {
expect.objectContaining({
data: expect.objectContaining({
email: 'test@example.com',
role: 'MODELER',
role: 'VIEWER',
}),
})
);
Expand Down Expand Up @@ -219,6 +225,107 @@ describe('AuthService', () => {
});
});

describe('requestPasswordReset', () => {
it('does nothing for unknown email (no token created)', async () => {
prismaMock.user.findUnique.mockResolvedValue(null);

await authService.requestPasswordReset('unknown@example.com');

expect(prismaMock.passwordResetToken.create).not.toHaveBeenCalled();
});

it('creates a hashed token and invalidates prior tokens', async () => {
prismaMock.user.findUnique.mockResolvedValue(mockUser);
prismaMock.passwordResetToken.updateMany.mockResolvedValue({ count: 0 });
prismaMock.passwordResetToken.create.mockResolvedValue({
id: 'token-1',
tokenHash: 'hashed-token',
userId: mockUser.id,
expiresAt: new Date(Date.now() + 3600000),
usedAt: null,
createdAt: new Date(),
});

await authService.requestPasswordReset('test@example.com');

// Should invalidate prior tokens
expect(prismaMock.passwordResetToken.updateMany).toHaveBeenCalledWith({
where: { userId: mockUser.id, usedAt: null },
data: expect.objectContaining({ usedAt: expect.any(Date) }),
});
// Should create new token with hash (not raw)
expect(prismaMock.passwordResetToken.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tokenHash: expect.any(String),
userId: mockUser.id,
expiresAt: expect.any(Date),
}),
});
});
});

describe('resetPassword', () => {
const mockResetToken = {
id: 'token-1',
tokenHash: 'hashed-token',
userId: mockUser.id,
expiresAt: new Date(Date.now() + 3600000),
usedAt: null,
createdAt: new Date(),
user: mockUser,
};

it('resets password successfully with valid token', async () => {
prismaMock.passwordResetToken.findUnique.mockResolvedValue(mockResetToken);
prismaMock.$transaction.mockResolvedValue([{}, {}]);

await expect(
authService.resetPassword('valid-raw-token', 'newpassword123')
).resolves.toBeUndefined();

expect(prismaMock.passwordResetToken.findUnique).toHaveBeenCalledWith({
where: { tokenHash: expect.any(String) },
include: { user: true },
});
});

it('throws error for invalid token', async () => {
prismaMock.passwordResetToken.findUnique.mockResolvedValue(null);

await expect(
authService.resetPassword('invalid-token', 'newpassword123')
).rejects.toThrow('Invalid or expired reset token');
});

it('throws error for already-used token', async () => {
prismaMock.passwordResetToken.findUnique.mockResolvedValue({
...mockResetToken,
usedAt: new Date(),
});

await expect(
authService.resetPassword('used-token', 'newpassword123')
).rejects.toThrow('This reset token has already been used');
});

it('throws error for expired token', async () => {
prismaMock.passwordResetToken.findUnique.mockResolvedValue({
...mockResetToken,
expiresAt: new Date(Date.now() - 1000), // expired
});

await expect(
authService.resetPassword('expired-token', 'newpassword123')
).rejects.toThrow('Invalid or expired reset token');
});

it('throws error when new password is too short', async () => {
await expect(
authService.resetPassword('some-token', 'short')
).rejects.toThrow('Password must be at least 6 characters long');
});
});

describe('verifyToken', () => {
it('returns payload for valid token', () => {
const token = jwt.sign(
Expand Down
Loading