merge: 최신 main을 Profile 포괄 권한 제거에 통합
This commit is contained in:
@@ -631,7 +631,7 @@ describe('gateway auth flow', () => {
|
||||
});
|
||||
|
||||
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
||||
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
|
||||
const { caller, users, kakaoProfile, sealPassword, sentTalkMessages, flushPublisher } = buildCaller();
|
||||
const emailOwner = await users.createUser({
|
||||
username: 'email-owner',
|
||||
password: 'owner-password',
|
||||
@@ -642,6 +642,7 @@ describe('gateway auth flow', () => {
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
emailOwner.passwordResetRequired = true;
|
||||
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
||||
kakaoProfile.id = 'different-kakao-id';
|
||||
|
||||
@@ -659,7 +660,14 @@ describe('gateway auth flow', () => {
|
||||
oauthSessionId: recovery.oauthSessionId,
|
||||
action: 'link_existing',
|
||||
});
|
||||
expect(linked.status).toBe('otp');
|
||||
expect(linked.status).toBe('password_setup');
|
||||
if (linked.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
expect(sentTalkMessages).toHaveLength(0);
|
||||
const passwordSet = await caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: linked.oauthSessionId,
|
||||
credential: sealPassword('replacement-password'),
|
||||
});
|
||||
expect(passwordSet.status).toBe('otp');
|
||||
expect(sentTalkMessages).toHaveLength(1);
|
||||
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
||||
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
||||
@@ -668,6 +676,108 @@ describe('gateway auth flow', () => {
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed');
|
||||
});
|
||||
|
||||
it('requires a one-time password setup before an imported Kakao account can receive a session', async () => {
|
||||
const { caller, users, sessions, kakaoProfile, sealPassword, sentTalkMessages } = buildCaller({
|
||||
kakaoId: 'imported-kakao-id',
|
||||
kakaoEmail: 'imported@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'imported-kakao-user',
|
||||
password: 'legacy-password',
|
||||
oauth: {
|
||||
type: 'KAKAO',
|
||||
id: kakaoProfile.id,
|
||||
email: kakaoProfile.email,
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const createSession = vi.spyOn(sessions, 'createSession');
|
||||
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
expect(login).toMatchObject({
|
||||
status: 'password_setup',
|
||||
email: 'imported@example.com',
|
||||
successStatus: 'login',
|
||||
});
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
expect(login).not.toHaveProperty('sessionToken');
|
||||
expect(createSession).not.toHaveBeenCalled();
|
||||
expect(sentTalkMessages).toHaveLength(0);
|
||||
|
||||
const setup = await caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-imported-password'),
|
||||
});
|
||||
expect(setup.status).toBe('otp');
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(false);
|
||||
expect(await users.verifyPassword(user, 'new-imported-password')).toBe(true);
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('another-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('rechecks sanctions before consuming a migrated password setup', async () => {
|
||||
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||
kakaoId: 'sanctioned-setup-id',
|
||||
kakaoEmail: 'sanctioned-setup@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'sanctioned-setup-user',
|
||||
password: 'legacy-password',
|
||||
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
await users.updateSanctions(user.id, { bannedUntil: '2099-01-01T00:00:00.000Z' });
|
||||
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('blocked-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||
});
|
||||
|
||||
it('consumes password setup when the provider identity changes before submission', async () => {
|
||||
const { caller, users, kakaoProfile, sealPassword } = buildCaller({
|
||||
kakaoId: 'setup-target-id',
|
||||
kakaoEmail: 'setup-target@example.com',
|
||||
});
|
||||
const user = await users.createUser({
|
||||
username: 'setup-target-user',
|
||||
password: 'legacy-password',
|
||||
oauth: { type: 'KAKAO', id: kakaoProfile.id, email: kakaoProfile.email, info: {} },
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
if (login.status !== 'password_setup') throw new Error('Expected migrated password setup.');
|
||||
kakaoProfile.id = 'changed-provider-id';
|
||||
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-target-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
expect((await users.findById(user.id))?.passwordResetRequired).toBe(true);
|
||||
await expect(
|
||||
caller.auth.kakaoSetPassword({
|
||||
oauthSessionId: login.oauthSessionId,
|
||||
credential: sealPassword('new-target-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
|
||||
});
|
||||
|
||||
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
||||
@@ -1147,6 +1257,7 @@ describe('account self service', () => {
|
||||
username: 'self-service',
|
||||
password: 'current-password',
|
||||
});
|
||||
user.passwordResetRequired = true;
|
||||
const session = await sessions.createSession(user);
|
||||
|
||||
await expect(
|
||||
@@ -1165,6 +1276,7 @@ describe('account self service', () => {
|
||||
|
||||
const refreshed = await users.findById(user.id);
|
||||
expect(refreshed && (await users.verifyPassword(refreshed, 'next-password'))).toBe(true);
|
||||
expect(refreshed?.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('revokes the session and schedules deletion after 30 days', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ const buildLocalUser = (graceStartedAt: Date): UserRecord => ({
|
||||
kakaoGraceStartedAt: graceStartedAt.toISOString(),
|
||||
passwordHash: 'unused',
|
||||
passwordSalt: '',
|
||||
passwordResetRequired: false,
|
||||
createdAt: graceStartedAt.toISOString(),
|
||||
});
|
||||
|
||||
@@ -90,6 +91,25 @@ describe('local account profile policy', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trust a migrated Kakao marker without a valid provider ID', () => {
|
||||
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = ' ';
|
||||
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: false,
|
||||
requiresKakaoVerification: true,
|
||||
accessAllowed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('extends account access with an administrator override without widening general creation grace', () => {
|
||||
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
|
||||
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
|
||||
|
||||
@@ -61,13 +61,15 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
});
|
||||
sessionIds.add(session.id);
|
||||
|
||||
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
|
||||
await expect(client.ttl(`${prefix}:oauth-session:${session.id}`)).resolves.toBeGreaterThan(0);
|
||||
const consumed = await Promise.all([store.consumeSession(session.id), store.consumeSession(session.id)]);
|
||||
expect(consumed.filter((value) => value !== null)).toHaveLength(1);
|
||||
expect(consumed.find((value) => value !== null)).toMatchObject({
|
||||
id: session.id,
|
||||
intent: 'link_existing',
|
||||
targetUserId,
|
||||
email: 'retained@example.test',
|
||||
});
|
||||
await expect(store.consumeSession(session.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('atomically consumes a successful code once', async () => {
|
||||
|
||||
@@ -34,10 +34,12 @@ describe('password credential compatibility', () => {
|
||||
});
|
||||
user.passwordSalt = 'core-salt';
|
||||
user.passwordHash = createHash('sha256').update('core-salt:current-password').digest('hex');
|
||||
user.passwordResetRequired = true;
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
expect(user.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('upgrades an imported ref double-SHA-512 credential after a successful login', async () => {
|
||||
@@ -52,10 +54,12 @@ describe('password credential compatibility', () => {
|
||||
const browserHash = createHash('sha512').update(`${globalSalt}current-password${globalSalt}`).digest('hex');
|
||||
user.passwordSalt = userSalt;
|
||||
user.passwordHash = createHash('sha512').update(`${userSalt}${browserHash}${userSalt}`).digest('hex');
|
||||
user.passwordResetRequired = true;
|
||||
|
||||
expect(await users.verifyPassword(user, 'current-password')).toBe(true);
|
||||
expect(user.passwordHash.startsWith('$argon2id$')).toBe(true);
|
||||
expect(user.passwordSalt).toBe('');
|
||||
expect(user.passwordResetRequired).toBe(false);
|
||||
});
|
||||
|
||||
it('does not accept an imported ref credential without the matching global salt', async () => {
|
||||
|
||||
@@ -38,8 +38,8 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
|
||||
controllerProtocol: RELEASE_CONTROLLER_PROTOCOL,
|
||||
gatewaySchemaHead: '20260817000000_remove_profile_manage_capability',
|
||||
gameSchemaHead: '20260817000000_add_general_access_batch',
|
||||
gatewaySchemaHead: '20260817001000_remove_profile_manage_capability',
|
||||
gameSchemaHead: '20260817001000_add_dedicated_legacy_archive',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,11 +61,7 @@ describe('readReleaseManifest', () => {
|
||||
|
||||
it('allows only the explicit controller self-upgrade boundary to cross protocol versions', async () => {
|
||||
const futureProtocol = RELEASE_CONTROLLER_PROTOCOL + 1;
|
||||
const workspace = await createWorkspace(
|
||||
'20260801000000_gateway',
|
||||
'20260801000000_game',
|
||||
futureProtocol
|
||||
);
|
||||
const workspace = await createWorkspace('20260801000000_gateway', '20260801000000_game', futureProtocol);
|
||||
|
||||
await expect(readReleaseManifest(workspace)).rejects.toThrow(
|
||||
`Release requires controller protocol ${futureProtocol}`
|
||||
|
||||
Reference in New Issue
Block a user