feat: add local signup and Kakao verification gate
This commit is contained in:
@@ -8,6 +8,7 @@ import type { GatewayOperationCreateInput, GatewayProfileRepository } from '../s
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
@@ -74,6 +75,9 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper
|
||||
oauthSessions: {} as never,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: false,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: 7,
|
||||
passwordEnvelope: createPasswordEnvelopeService(),
|
||||
profiles,
|
||||
orchestrator: {
|
||||
start: () => {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { constants, publicEncrypt } from 'node:crypto';
|
||||
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
@@ -13,8 +14,9 @@ import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.
|
||||
import { appRouter } from '../src/router.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
const sessions = new InMemoryGatewaySessionService({
|
||||
sessionTtlSeconds: 3600,
|
||||
@@ -29,10 +31,13 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
redirectUri: '',
|
||||
oauthHost: '',
|
||||
apiHost: '',
|
||||
buildAuthUrl: () => '',
|
||||
exchangeCode: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
buildAuthUrl: (state: string) => `https://kauth.example.test/authorize?state=${state}`,
|
||||
exchangeCode: async () => ({
|
||||
accessToken: 'access-token',
|
||||
accessTokenExpiresIn: 3600,
|
||||
refreshToken: 'refresh-token',
|
||||
refreshTokenExpiresIn: 86400,
|
||||
}),
|
||||
refreshToken: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
@@ -48,9 +53,33 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
}),
|
||||
sendTalkMessage: async () => {},
|
||||
};
|
||||
const profileRows = [
|
||||
{
|
||||
profileName: 'che:default',
|
||||
profile: 'che',
|
||||
scenario: 'default',
|
||||
apiPort: 15003,
|
||||
status: 'RUNNING' as const,
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
meta: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
profileName: 'hwe:default',
|
||||
profile: 'hwe',
|
||||
scenario: 'default',
|
||||
apiPort: 15015,
|
||||
status: 'RUNNING' as const,
|
||||
buildStatus: 'SUCCEEDED' as const,
|
||||
meta: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
const profiles = {
|
||||
listProfiles: async () => [],
|
||||
getProfile: async () => null,
|
||||
listProfiles: async () => profileRows,
|
||||
getProfile: async (profileName: string) => profileRows.find((profile) => profile.profileName === profileName) ?? null,
|
||||
upsertProfile: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
@@ -91,7 +120,40 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
}),
|
||||
listRuntimeStates: async () => [],
|
||||
};
|
||||
const profileStatus = new InMemoryProfileStatusService();
|
||||
const profileStatus = new InMemoryProfileStatusService(
|
||||
profileRows.map((profile) => ({
|
||||
profileName: profile.profileName,
|
||||
profile: profile.profile,
|
||||
scenario: profile.scenario,
|
||||
status: profile.status,
|
||||
apiPort: profile.apiPort,
|
||||
runtime: {
|
||||
apiRunning: true,
|
||||
daemonRunning: true,
|
||||
auctionRunning: false,
|
||||
battleSimRunning: false,
|
||||
tournamentRunning: false,
|
||||
},
|
||||
korName: profile.profile,
|
||||
color: '#fff',
|
||||
}))
|
||||
);
|
||||
const passwordEnvelope = createPasswordEnvelopeService();
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const sealPassword = (password: string) => {
|
||||
const key = passwordEnvelope.getPublicKey();
|
||||
return {
|
||||
keyId: key.keyId,
|
||||
ciphertext: publicEncrypt(
|
||||
{
|
||||
key: key.publicKeyPem,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from(password, 'utf8')
|
||||
).toString('base64'),
|
||||
};
|
||||
};
|
||||
const caller = appRouter.createCaller(
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
@@ -105,10 +167,13 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
userIconDir: options.userIconDir,
|
||||
userIconPublicUrl: 'http://localhost/user-icons',
|
||||
adminLocalAccountEnabled: false,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: options.localAccountGraceDays ?? 7,
|
||||
passwordEnvelope,
|
||||
profiles,
|
||||
orchestrator,
|
||||
profileStatus,
|
||||
requestHeaders: {},
|
||||
requestHeaders,
|
||||
prisma: {
|
||||
appUser: {
|
||||
findFirst: async () => null,
|
||||
@@ -116,10 +181,149 @@ const buildCaller = (options: { userIconDir?: string } = {}) => {
|
||||
} as unknown as GatewayPrismaClient,
|
||||
})
|
||||
);
|
||||
return { caller, oauthSessions, users, sessions };
|
||||
return {
|
||||
caller,
|
||||
oauthSessions,
|
||||
users,
|
||||
sessions,
|
||||
sealPassword,
|
||||
setSessionHeader: (sessionToken: string) => {
|
||||
requestHeaders['x-session-token'] = sessionToken;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe('gateway auth flow', () => {
|
||||
it('registers a local account first and accepts an encrypted password login', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'LOCAL-User',
|
||||
credential: sealPassword('비밀번호-password'),
|
||||
displayName: '로컬유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
expect(register.user).toMatchObject({
|
||||
username: 'local-user',
|
||||
displayName: '로컬유저',
|
||||
kakaoVerified: false,
|
||||
});
|
||||
const stored = await users.findByUsername('local-user');
|
||||
expect(stored?.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(stored?.thirdPartyUse).toBe(false);
|
||||
expect(stored?.termsAcceptedAt).toBeTruthy();
|
||||
expect(stored?.privacyAcceptedAt).toBeTruthy();
|
||||
|
||||
const login = await caller.auth.login({
|
||||
username: 'LOCAL-USER',
|
||||
credential: sealPassword('비밀번호-password'),
|
||||
});
|
||||
expect(login.user.username).toBe('local-user');
|
||||
});
|
||||
|
||||
it('blocks pre-verification general creation on che but grants the hwe grace period', async () => {
|
||||
const { caller, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'policy-user',
|
||||
credential: sealPassword('policy-password'),
|
||||
displayName: '정책유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
const che = await caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'che:default',
|
||||
});
|
||||
const hwe = await caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'hwe:default',
|
||||
});
|
||||
const chePayload = decryptGameSessionToken(che.gameToken, 'test-secret');
|
||||
const hwePayload = decryptGameSessionToken(hwe.gameToken, 'test-secret');
|
||||
|
||||
expect(chePayload?.identity).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
canCreateGeneral: false,
|
||||
requiresKakaoVerification: true,
|
||||
});
|
||||
expect(hwePayload?.identity).toMatchObject({
|
||||
kakaoVerified: false,
|
||||
canCreateGeneral: true,
|
||||
requiresKakaoVerification: true,
|
||||
});
|
||||
|
||||
setSessionHeader(register.sessionToken);
|
||||
const profileList = await caller.lobby.profiles();
|
||||
expect(profileList.find((profile) => profile.profile === 'che')?.localAccountPolicy?.canCreateGeneral).toBe(
|
||||
false
|
||||
);
|
||||
expect(profileList.find((profile) => profile.profile === 'hwe')?.localAccountPolicy?.canCreateGeneral).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects continued game access after the local account grace period', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 7 });
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'expired-user',
|
||||
credential: sealPassword('expired-password'),
|
||||
displayName: '만료유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('expired-user');
|
||||
expect(user).not.toBeNull();
|
||||
if (user) {
|
||||
user.kakaoGraceStartedAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
await expect(
|
||||
caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'hwe:default',
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: expect.stringContaining('유예기간'),
|
||||
});
|
||||
});
|
||||
|
||||
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
|
||||
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'verify-user',
|
||||
credential: sealPassword('verify-password'),
|
||||
displayName: '인증유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
setSessionHeader(register.sessionToken);
|
||||
const start = await caller.auth.kakaoStart({ mode: 'verify' });
|
||||
const verified = await caller.auth.kakaoExchange({
|
||||
code: 'oauth-code',
|
||||
state: start.state,
|
||||
});
|
||||
|
||||
expect(verified.status).toBe('verified');
|
||||
if (verified.status !== 'verified') {
|
||||
throw new Error('Expected verified result.');
|
||||
}
|
||||
expect(verified.user.kakaoVerified).toBe(true);
|
||||
const stored = await users.findByUsername('verify-user');
|
||||
expect(stored).toMatchObject({
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: '1',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
expect(stored?.kakaoVerifiedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('carries the bootstrap superuser role into game sessions', async () => {
|
||||
const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
|
||||
process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
|
||||
@@ -151,7 +355,7 @@ describe('gateway auth flow', () => {
|
||||
});
|
||||
|
||||
it('registers and issues a game session', async () => {
|
||||
const { caller, oauthSessions } = buildCaller();
|
||||
const { caller, oauthSessions, sealPassword } = buildCaller();
|
||||
const oauthSession = await oauthSessions.createSession({
|
||||
mode: 'login',
|
||||
kakaoId: '1',
|
||||
@@ -165,8 +369,11 @@ describe('gateway auth flow', () => {
|
||||
const register = await caller.auth.register({
|
||||
oauthSessionId: oauthSession.id,
|
||||
username: 'tester',
|
||||
password: 'secretpass',
|
||||
credential: sealPassword('secretpass'),
|
||||
displayName: 'Tester',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
|
||||
expect(register.user.username).toBe('tester');
|
||||
@@ -191,7 +398,7 @@ describe('gateway auth flow', () => {
|
||||
|
||||
describe('account self service', () => {
|
||||
it('changes only the authenticated user password after verifying the current password', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const { caller, users, sessions, sealPassword } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'self-service',
|
||||
password: 'current-password',
|
||||
@@ -199,17 +406,17 @@ describe('account self service', () => {
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'wrong-password',
|
||||
newPassword: 'next-password',
|
||||
caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: sealPassword('wrong-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
|
||||
await caller.account.changePassword({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'current-password',
|
||||
newPassword: 'next-password',
|
||||
currentCredential: sealPassword('current-password'),
|
||||
newCredential: sealPassword('next-password'),
|
||||
});
|
||||
|
||||
const refreshed = await users.findById(user.id);
|
||||
@@ -217,7 +424,7 @@ describe('account self service', () => {
|
||||
});
|
||||
|
||||
it('revokes the session and schedules deletion after 30 days', async () => {
|
||||
const { caller, users, sessions } = buildCaller();
|
||||
const { caller, users, sessions, sealPassword } = buildCaller();
|
||||
const user = await users.createUser({
|
||||
username: 'delete-self',
|
||||
password: 'current-password',
|
||||
@@ -226,7 +433,7 @@ describe('account self service', () => {
|
||||
|
||||
const result = await caller.account.scheduleDeletion({
|
||||
sessionToken: session.sessionToken,
|
||||
currentPassword: 'current-password',
|
||||
currentCredential: sealPassword('current-password'),
|
||||
});
|
||||
|
||||
expect(new Date(result.deleteAfter).getTime()).toBeGreaterThan(Date.now() + 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveLocalAccountProfilePolicy } from '../src/auth/localAccountPolicy.js';
|
||||
import type { UserRecord } from '../src/auth/userRepository.js';
|
||||
|
||||
const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
|
||||
id: 'local-user',
|
||||
username: 'local-user',
|
||||
displayName: '로컬유저',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType: 'NONE',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
thirdPartyUse: false,
|
||||
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
||||
passwordHash: 'unused',
|
||||
passwordSalt: '',
|
||||
createdAt: graceStartedAt.toISOString(),
|
||||
});
|
||||
|
||||
describe('local account profile policy', () => {
|
||||
it.each(['che', 'kwe', 'twe'])('%s blocks general creation before Kakao verification', (profile) => {
|
||||
const now = new Date('2026-07-26T00:00:00.000Z');
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(now),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(policy.accessAllowed).toBe(true);
|
||||
expect(policy.canCreateGeneral).toBe(false);
|
||||
expect(policy.generalCreationGraceDays).toBe(0);
|
||||
});
|
||||
|
||||
it.each(['nya', 'pya', 'hwe'])('%s allows general creation during the configured grace period', (profile) => {
|
||||
const start = new Date('2026-07-20T00:00:00.000Z');
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile,
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(start),
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy.accessAllowed).toBe(true);
|
||||
expect(policy.canCreateGeneral).toBe(true);
|
||||
expect(policy.generalCreationGraceDays).toBe(7);
|
||||
});
|
||||
|
||||
it('honors profile metadata overrides and blocks all access after expiry', () => {
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'hwe',
|
||||
profileMeta: {
|
||||
localAccountAccessGraceDays: 3,
|
||||
localAccountGeneralCreationGraceDays: 1,
|
||||
},
|
||||
defaultGraceDays: 7,
|
||||
user: buildLocalUser(new Date('2026-07-20T00:00:00.000Z')),
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
accessAllowed: false,
|
||||
canCreateGeneral: false,
|
||||
accessGraceDays: 3,
|
||||
generalCreationGraceDays: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('removes grace restrictions once Kakao is verified', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = '1';
|
||||
user.email = 'verified@example.test';
|
||||
user.kakaoVerifiedAt = '2026-07-26T00:00:00.000Z';
|
||||
const policy = resolveLocalAccountProfilePolicy({
|
||||
profile: 'che',
|
||||
defaultGraceDays: 0,
|
||||
user,
|
||||
now: new Date('2026-07-26T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(policy).toMatchObject({
|
||||
kakaoVerified: true,
|
||||
requiresKakaoVerification: false,
|
||||
accessAllowed: true,
|
||||
canCreateGeneral: true,
|
||||
graceEndsAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { constants, createHash, publicEncrypt } from 'node:crypto';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
import { createPasswordHasher } from '../src/auth/passwordHasher.js';
|
||||
|
||||
describe('password credential compatibility', () => {
|
||||
it('seals browser passwords with RSA-OAEP before opening them at the gateway', () => {
|
||||
const service = createPasswordEnvelopeService();
|
||||
const publicKey = service.getPublicKey();
|
||||
const ciphertext = publicEncrypt(
|
||||
{
|
||||
key: publicKey.publicKeyPem,
|
||||
padding: constants.RSA_PKCS1_OAEP_PADDING,
|
||||
oaepHash: 'sha256',
|
||||
},
|
||||
Buffer.from('터널에-보이지-않는-비밀번호', 'utf8')
|
||||
).toString('base64');
|
||||
|
||||
expect(ciphertext).not.toContain('비밀번호');
|
||||
expect(service.open({ keyId: publicKey.keyId, ciphertext })).toBe('터널에-보이지-않는-비밀번호');
|
||||
expect(() => service.open({ keyId: 'stale-key', ciphertext })).toThrow(/key has changed/i);
|
||||
expect(() => service.open({ keyId: publicKey.keyId, ciphertext: '*not-base64*' })).toThrow(/base64/i);
|
||||
});
|
||||
|
||||
it('upgrades the former core SHA-256 credential after a successful login', async () => {
|
||||
const hasher = createPasswordHasher();
|
||||
const users = createInMemoryUserRepository(hasher);
|
||||
const user = await users.createUser({
|
||||
username: 'core-legacy',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordSalt = 'core-salt';
|
||||
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
});
|
||||
|
||||
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
||||
const globalSalt = 'ref-global-salt';
|
||||
const hasher = createPasswordHasher({ legacyGlobalSalt: globalSalt });
|
||||
const users = createInMemoryUserRepository(hasher);
|
||||
const user = await users.createUser({
|
||||
username: 'ref-legacy',
|
||||
password: 'current-password',
|
||||
});
|
||||
const userSalt = 'ref-user-salt';
|
||||
const browserHash = createHash('sha512')
|
||||
.update(`${globalSalt}current-password${globalSalt}`)
|
||||
.digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
});
|
||||
|
||||
it('does not accept an imported ref credential without the matching global salt', async () => {
|
||||
const users = createInMemoryUserRepository(createPasswordHasher());
|
||||
const user = await users.createUser({
|
||||
username: 'ref-no-salt',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordSalt = 'ref-user-salt';
|
||||
user.passwordHash = 'a'.repeat(128);
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(false);
|
||||
expect(user.passwordHash).toBe('a'.repeat(128));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user