Files

356 lines
9.1 KiB
TypeScript

import { UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Test, TestingModule } from "@nestjs/testing";
import { AuthMailService } from "../src/auth/auth-mail.service";
import { AuthService } from "../src/auth/auth.service";
import { PrismaService } from "../src/prisma/prisma.service";
import { DataEncryptionService } from "../src/security/data-encryption.service";
type UserRecord = {
id: string;
email: string;
emailHash: string;
nickname: string | null;
avatarUrl: string | null;
};
type RefreshTokenRecord = {
id: string;
userId: string;
tokenHash: string;
expiresAt: Date;
revokedAt: Date | null;
createdAt: Date;
};
type UserSecurityRecord = {
userId: string;
twoFactorEnabled: boolean;
twoFactorSecret: string | null;
};
class InMemoryAuthPrismaService {
private userIdSequence = 1;
private refreshTokenIdSequence = 1;
private users: UserRecord[] = [];
private refreshTokens: RefreshTokenRecord[] = [];
private userSecurities: UserSecurityRecord[] = [];
readonly user = {
upsert: async (args: {
where: {
emailHash: string;
};
update: Record<string, never>;
create: {
email: string;
emailHash: string;
};
select: {
id: true;
email: true;
};
}) => {
const existingUser = this.users.find((user) => user.emailHash === args.where.emailHash);
if (existingUser) {
return {
id: existingUser.id,
email: existingUser.email
};
}
const createdUser: UserRecord = {
id: `user_${this.userIdSequence++}`,
email: args.create.email,
emailHash: args.create.emailHash,
nickname: null,
avatarUrl: null
};
this.users.push(createdUser);
return {
id: createdUser.id,
email: createdUser.email
};
}
};
readonly refreshToken = {
create: async (args: {
data: {
userId: string;
tokenHash: string;
expiresAt: Date;
};
}) => {
const refreshToken: RefreshTokenRecord = {
id: `refresh_${this.refreshTokenIdSequence++}`,
userId: args.data.userId,
tokenHash: args.data.tokenHash,
expiresAt: args.data.expiresAt,
revokedAt: null,
createdAt: new Date()
};
this.refreshTokens.push(refreshToken);
return refreshToken;
},
findUnique: async (args: {
where: {
tokenHash: string;
};
include: {
user: {
select: {
id: true;
email: true;
};
};
};
}) => {
const refreshToken = this.refreshTokens.find(
(item) => item.tokenHash === args.where.tokenHash
);
if (!refreshToken) {
return null;
}
const user = this.users.find((item) => item.id === refreshToken.userId);
if (!user) {
throw new Error("user not found");
}
return {
...refreshToken,
user: {
id: user.id,
email: user.email
}
};
},
update: async (args: {
where: {
id: string;
};
data: {
revokedAt: Date;
};
}) => {
const refreshToken = this.refreshTokens.find((item) => item.id === args.where.id);
if (!refreshToken) {
throw new Error("refresh token not found");
}
refreshToken.revokedAt = args.data.revokedAt;
return refreshToken;
},
updateMany: async (args: {
where: {
tokenHash: string;
revokedAt: null;
};
data: {
revokedAt: Date;
};
}) => {
let count = 0;
for (const refreshToken of this.refreshTokens) {
if (refreshToken.tokenHash !== args.where.tokenHash || refreshToken.revokedAt !== null) {
continue;
}
refreshToken.revokedAt = args.data.revokedAt;
count += 1;
}
return { count };
}
};
readonly userSecurity = {
upsert: async (args: {
where: {
userId: string;
};
update: {
twoFactorSecret: string;
twoFactorEnabled: boolean;
};
create: {
userId: string;
twoFactorSecret: string;
twoFactorEnabled: boolean;
};
}) => {
const existingSecurity = this.userSecurities.find(
(item) => item.userId === args.where.userId
);
if (existingSecurity) {
existingSecurity.twoFactorSecret = args.update.twoFactorSecret;
existingSecurity.twoFactorEnabled = args.update.twoFactorEnabled;
return existingSecurity;
}
const createdSecurity: UserSecurityRecord = {
userId: args.create.userId,
twoFactorSecret: args.create.twoFactorSecret,
twoFactorEnabled: args.create.twoFactorEnabled
};
this.userSecurities.push(createdSecurity);
return createdSecurity;
},
findUnique: async (args: {
where: {
userId: string;
};
select: {
twoFactorSecret: true;
};
}) => {
const security = this.userSecurities.find((item) => item.userId === args.where.userId);
if (!security) {
return null;
}
return {
twoFactorSecret: security.twoFactorSecret
};
},
update: async (args: {
where: {
userId: string;
};
data: {
twoFactorEnabled: boolean;
};
}) => {
const security = this.userSecurities.find((item) => item.userId === args.where.userId);
if (!security) {
throw new Error("user security not found");
}
security.twoFactorEnabled = args.data.twoFactorEnabled;
return security;
}
};
getUsers(): UserRecord[] {
return [...this.users];
}
}
class MockAuthMailService {
readonly sentMessages: Array<{
email: string;
code: string;
ttlSeconds: number;
}> = [];
async sendLoginCode(email: string, code: string, ttlSeconds: number): Promise<void> {
this.sentMessages.push({
email,
code,
ttlSeconds
});
}
}
describe("AuthService", () => {
let authService: AuthService;
let prismaService: InMemoryAuthPrismaService;
let authMailService: MockAuthMailService;
beforeEach(async () => {
prismaService = new InMemoryAuthPrismaService();
authMailService = new MockAuthMailService();
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
DataEncryptionService,
{
provide: PrismaService,
useValue: prismaService
},
{
provide: AuthMailService,
useValue: authMailService
},
{
provide: JwtService,
useValue: {
signAsync: async (payload: Record<string, unknown>) =>
`signed-${String(payload["sub"])}-${String(payload["email"])}`
}
},
{
provide: ConfigService,
useValue: {
get: (key: string) => {
switch (key) {
case "AUTH_EMAIL_CODE_TTL_SECONDS":
return "300";
case "AUTH_ACCESS_EXPIRES_IN_SECONDS":
return "900";
case "AUTH_REFRESH_EXPIRES_IN_SECONDS":
return "2592000";
case "AUTH_TOTP_ISSUER":
return "TodoList";
case "DATA_ENCRYPTION_SECRET":
return "test-data-encryption-secret";
default:
return undefined;
}
}
}
}
]
}).compile();
authService = moduleRef.get(AuthService);
});
it("should encrypt user email in database while keeping login flow available", async () => {
await authService.sendEmailCode("User@Example.com");
expect(authMailService.sentMessages).toHaveLength(1);
expect(authMailService.sentMessages[0]?.email).toBe("user@example.com");
const loginResult = await authService.loginWithEmailCode(
"USER@example.com",
authMailService.sentMessages[0]?.code ?? ""
);
expect(loginResult.user.email).toBe("user@example.com");
expect(loginResult.accessToken).toContain("user@example.com");
const storedUser = prismaService.getUsers()[0];
expect(storedUser?.email).not.toBe("user@example.com");
expect(storedUser?.emailHash).toMatch(/^[a-f0-9]{64}$/);
});
it("should decrypt user email when refreshing token", async () => {
await authService.sendEmailCode("refresh@example.com");
const loginResult = await authService.loginWithEmailCode(
"refresh@example.com",
authMailService.sentMessages[0]?.code ?? ""
);
const refreshResult = await authService.refreshTokens(loginResult.refreshToken);
expect(refreshResult.user.email).toBe("refresh@example.com");
expect(refreshResult.accessToken).toContain("refresh@example.com");
});
it("should reject invalid verification code", async () => {
await authService.sendEmailCode("invalid@example.com");
await expect(
authService.loginWithEmailCode("invalid@example.com", "000000")
).rejects.toBeInstanceOf(UnauthorizedException);
});
});