온라인 인증 시도와 처리 비용 및 Kakao 응답 경계를 제한한다
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
|
||||
|
||||
describe.skipIf(!redisUrl)('Redis authentication budget atomicity', () => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
const prefix = `auth-budget-test:${randomUUID()}`;
|
||||
const store = new RedisAuthCounterStore(client);
|
||||
const keys = ['subject', 'global', 'expires', 'invalid'].map((kind) => `${prefix}:${kind}`);
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
|
||||
it('admits exactly ten concurrent attempts and does not charge other buckets on rejection', async () => {
|
||||
const attempts = await Promise.all(
|
||||
Array.from({ length: 100 }, () =>
|
||||
store.consume([
|
||||
{ key: keys[0]!, limit: 10, windowMs: 60_000 },
|
||||
{ key: keys[1]!, limit: 1000, windowMs: 60_000 },
|
||||
])
|
||||
)
|
||||
);
|
||||
expect(attempts.filter((retry) => retry === 0)).toHaveLength(10);
|
||||
expect(attempts.filter((retry) => retry > 0)).toHaveLength(90);
|
||||
expect(await client.get(keys[0]!)).toBe('10');
|
||||
expect(await client.get(keys[1]!)).toBe('10');
|
||||
expect(await client.pTTL(keys[0]!)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the original expiry under repeated rejected attempts and permits recovery', async () => {
|
||||
const counters = [{ key: keys[2]!, limit: 1, windowMs: 120 }];
|
||||
expect(await store.consume(counters)).toBe(0);
|
||||
const before = await client.pTTL(keys[2]!);
|
||||
expect(await store.consume(counters)).toBeGreaterThan(0);
|
||||
expect(await client.pTTL(keys[2]!)).toBeLessThanOrEqual(before);
|
||||
await expect.poll(() => client.exists(keys[2]!), { interval: 20, timeout: 1000 }).toBe(0);
|
||||
expect(await store.consume(counters)).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects a malformed counter without an expiry instead of permanently locking the account', async () => {
|
||||
await client.set(keys[3]!, '10');
|
||||
await expect(store.consume([{ key: keys[3]!, limit: 10, windowMs: 60_000 }])).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthAttemptBudget, AuthBudgetError, InMemoryAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
|
||||
describe('authentication attempt and resource budgets', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shares a login subject across clients without exposing account or address in keys', async () => {
|
||||
const store = new InMemoryAuthCounterStore();
|
||||
const consume = vi.spyOn(store, 'consume');
|
||||
const budget = new AuthAttemptBudget(store, 'fixture', 'fixture-secret');
|
||||
for (let i = 0; i < 10; i++)
|
||||
await budget.run('auth.login', `client-${i}`, 'login:fixture-user', async () => 'ok');
|
||||
const work = vi.fn(async () => 'must not run');
|
||||
await expect(budget.run('auth.login', 'another-client', 'login:fixture-user', work)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
retryAfterSeconds: 60,
|
||||
});
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(consume.mock.calls)).not.toContain('fixture-user');
|
||||
expect(JSON.stringify(consume.mock.calls)).not.toContain('client-');
|
||||
await expect(budget.run('auth.login', 'another-client', 'login:other-user', async () => 'ok')).resolves.toBe(
|
||||
'ok'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not extend a blocked account window and admits it after expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
for (let i = 0; i < 10; i++) await budget.run('auth.login', 'client', 'login:user', async () => null);
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
await expect(budget.run('auth.login', 'client', 'login:user', async () => null)).rejects.toMatchObject({
|
||||
retryAfterSeconds: 1,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await expect(budget.run('auth.login', 'client', 'login:user', async () => 'recovered')).resolves.toBe(
|
||||
'recovered'
|
||||
);
|
||||
});
|
||||
|
||||
it('limits public auth traffic per peer and across peers without charging rejected requests', async () => {
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-a', undefined, async () => null);
|
||||
await expect(budget.run('auth.kakaoStart', 'peer-a', undefined, async () => null)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
});
|
||||
for (let i = 0; i < 600; i++) await budget.run('auth.passwordKey', 'peer-b', undefined, async () => null);
|
||||
await expect(budget.run('auth.kakaoStart', 'peer-c', undefined, async () => null)).rejects.toMatchObject({
|
||||
reason: 'limited',
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds expensive work and releases capacity after resolver failures', async () => {
|
||||
const budget = new AuthAttemptBudget(new InMemoryAuthCounterStore(), 'fixture', 'secret');
|
||||
let release!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const work = vi.fn(async () => {
|
||||
await pending;
|
||||
throw new Error('fixture resolver failure');
|
||||
});
|
||||
const running = Array.from({ length: 4 }, () =>
|
||||
budget.run('auth.login', 'client', undefined, work).catch(() => null)
|
||||
);
|
||||
await vi.waitFor(() => expect(work).toHaveBeenCalledTimes(4));
|
||||
const rejected = vi.fn(async () => null);
|
||||
await expect(budget.run('auth.registerLocal', 'client', undefined, rejected)).rejects.toBeInstanceOf(
|
||||
AuthBudgetError
|
||||
);
|
||||
expect(rejected).not.toHaveBeenCalled();
|
||||
release();
|
||||
await Promise.all(running);
|
||||
await expect(budget.run('auth.login', 'client', undefined, async () => 'ok')).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('bounds unfinished checks after caller timeouts and recovers only after they settle', async () => {
|
||||
vi.useFakeTimers();
|
||||
let release!: (retry: number) => void;
|
||||
const waiting = new Promise<number>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const store = { consume: vi.fn(() => waiting) };
|
||||
const budget = new AuthAttemptBudget(store, 'fixture', 'secret');
|
||||
const work = vi.fn(async () => 'ok');
|
||||
const requests = Array.from({ length: 32 }, () =>
|
||||
budget.run('auth.passwordKey', 'client', undefined, work).catch((error: unknown) => error)
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
expect(
|
||||
(await Promise.all(requests)).every(
|
||||
(error) => error instanceof AuthBudgetError && error.reason === 'unavailable'
|
||||
)
|
||||
).toBe(true);
|
||||
const actorLookup = vi.fn(async () => undefined);
|
||||
await expect(budget.run('auth.login', 'client', actorLookup, work)).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
expect(actorLookup).not.toHaveBeenCalled();
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
expect(store.consume).toHaveBeenCalledTimes(32);
|
||||
release(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await expect(budget.run('auth.login', 'client', undefined, work)).resolves.toBe('ok');
|
||||
expect(work).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed before credential work when the counter backend errors or stalls', async () => {
|
||||
const work = vi.fn(async () => null);
|
||||
const failing = new AuthAttemptBudget(
|
||||
{
|
||||
consume: async () => {
|
||||
throw new Error('private-backend-error');
|
||||
},
|
||||
},
|
||||
'fixture',
|
||||
'secret'
|
||||
);
|
||||
await expect(failing.run('auth.login', 'client', undefined, work)).rejects.toMatchObject({
|
||||
reason: 'unavailable',
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
const stalled = new AuthAttemptBudget({ consume: () => new Promise(() => {}) }, 'fixture', 'secret');
|
||||
const result = stalled.run('auth.login', 'client', undefined, work).catch((error: unknown) => error);
|
||||
await vi.advanceTimersByTimeAsync(2001);
|
||||
expect(await result).toMatchObject({ reason: 'unavailable' });
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { constants, publicEncrypt, randomUUID } from 'node:crypto';
|
||||
import fastify, { type FastifyRequest } from 'fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import { createClient } from 'redis';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common';
|
||||
import { createGatewayApiContext } from '../src/context.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js';
|
||||
import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js';
|
||||
import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_AUTH_REDIS_TEST_URL;
|
||||
|
||||
describe.skipIf(!redisUrl)('authentication budgets over real HTTP and Redis', () => {
|
||||
const client = createClient({ url: redisUrl });
|
||||
const prefix = `auth-budget-http-test:${randomUUID()}`;
|
||||
const envelope = createPasswordEnvelopeService();
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
});
|
||||
afterAll(async () => {
|
||||
const keys: string[] = [];
|
||||
for await (const page of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) keys.push(...page);
|
||||
if (keys.length) await client.del(keys);
|
||||
await client.quit();
|
||||
});
|
||||
const credential = (password: string) => {
|
||||
const key = envelope.getPublicKey();
|
||||
return {
|
||||
keyId: key.keyId,
|
||||
ciphertext: publicEncrypt(
|
||||
{ key: key.publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||
Buffer.from(password)
|
||||
).toString('base64'),
|
||||
};
|
||||
};
|
||||
const fixture = async (available = true) => {
|
||||
const users = createInMemoryUserRepository();
|
||||
await users.createUser({ username: 'target-user', password: 'correct-password' });
|
||||
await users.createUser({ username: 'other-user', password: 'correct-password' });
|
||||
const requestIps = new Set<string>();
|
||||
const peer = (request: FastifyRequest): string => {
|
||||
requestIps.add(request.ip);
|
||||
return request.ip;
|
||||
};
|
||||
const verify = vi.spyOn(users, 'verifyPassword');
|
||||
const open = vi.fn(envelope.open);
|
||||
const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600 });
|
||||
const authBudget = new AuthAttemptBudget(
|
||||
new RedisAuthCounterStore(available ? client : client.duplicate()),
|
||||
`${prefix}:${randomUUID()}`,
|
||||
'synthetic-budget-key'
|
||||
);
|
||||
const app = fastify({ trustProxy: false });
|
||||
await app.register(fastifyTRPCPlugin, {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
...trpcJsonBodyHttpServerOptions,
|
||||
createContext: ({ req }: { req: FastifyRequest }) =>
|
||||
createGatewayApiContext({
|
||||
users,
|
||||
sessions,
|
||||
passwordEnvelope: { ...envelope, open },
|
||||
authBudget,
|
||||
requestIp: peer(req),
|
||||
requestHeaders: req.headers,
|
||||
flushPublisher: { publishUserFlush: async () => {} },
|
||||
gameTokenSecret: 'synthetic-game-key',
|
||||
gameSessionTtlSeconds: 600,
|
||||
kakaoClient: {} as never,
|
||||
oauthSessions: {} as never,
|
||||
publicBaseUrl: 'http://localhost',
|
||||
adminLocalAccountEnabled: true,
|
||||
localRegistrationEnabled: true,
|
||||
localAccountGraceDays: 7,
|
||||
profiles: {} as never,
|
||||
orchestrator: {} as never,
|
||||
profileStatus: {} as never,
|
||||
prisma: {} as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const address = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const post = (operation: string, input: unknown, headers: Record<string, string> = {}) =>
|
||||
fetch(`${address}/trpc/${operation}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...headers },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return { app, address, post, users, sessions, verify, open, requestIps };
|
||||
};
|
||||
|
||||
it('blocks an eleventh normalized account attempt before RSA/Argon2 despite spoofed forwarding headers', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const response = await f.post(
|
||||
'auth.login',
|
||||
{ username: i % 2 ? ' TARGET-USER ' : 'target-user', credential: credential('wrong-password') },
|
||||
{ 'x-forwarded-for': `198.51.100.${i + 1}` }
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
expect(f.requestIps.size).toBe(1);
|
||||
expect(f.requestIps.has('127.0.0.1')).toBe(true);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
const denied = await f.post(
|
||||
'auth.login',
|
||||
{ username: 'target-user', credential: credential('correct-password') },
|
||||
{ 'x-forwarded-for': '203.0.113.99' }
|
||||
);
|
||||
expect(denied.status).toBe(429);
|
||||
expect(JSON.stringify(await denied.json())).not.toContain('stack');
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'other-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(200);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('charges each operation in a tRPC batch instead of granting one budget per HTTP request', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const statuses: number[] = [];
|
||||
for (let round = 0; round < 4; round++) {
|
||||
const input = Object.fromEntries(
|
||||
Array.from({ length: 3 }, (_value, i) => [
|
||||
String(i),
|
||||
{ username: 'target-user', credential: credential('wrong-password') },
|
||||
])
|
||||
);
|
||||
const response = await f.post('auth.login,auth.login,auth.login?batch=1', input);
|
||||
const body = (await response.json()) as Array<{ error: { data: { httpStatus: number } } }>;
|
||||
statuses.push(...body.map((entry) => entry.error.data.httpStatus));
|
||||
}
|
||||
expect(statuses.filter((status) => status === 401)).toHaveLength(10);
|
||||
expect(statuses.filter((status) => status === 429)).toHaveLength(2);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('shares the password/deletion subject across sessions of the same authenticated actor', async () => {
|
||||
const f = await fixture();
|
||||
const user = await f.users.findByUsername('target-user');
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const session = await f.sessions.createSession(user!);
|
||||
const response = await f.post(i % 2 ? 'account.changePassword' : 'account.scheduleDeletion', {
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: credential('wrong-password'),
|
||||
newCredential: credential('new-password'),
|
||||
});
|
||||
expect(response.status).toBe(401);
|
||||
}
|
||||
const session = await f.sessions.createSession(user!);
|
||||
expect(
|
||||
(
|
||||
await f.post('account.scheduleDeletion', {
|
||||
sessionToken: session.sessionToken,
|
||||
currentCredential: credential('correct-password'),
|
||||
})
|
||||
).status
|
||||
).toBe(429);
|
||||
expect(f.verify).toHaveBeenCalledTimes(10);
|
||||
expect(f.open).toHaveBeenCalledTimes(10);
|
||||
expect((await f.users.findById(user!.id))?.deleteAfter).toBeUndefined();
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on unavailable protection while keeping logout independent of its counters', async () => {
|
||||
const f = await fixture(false);
|
||||
try {
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(503);
|
||||
expect(f.open).not.toHaveBeenCalled();
|
||||
expect(f.verify).not.toHaveBeenCalled();
|
||||
const user = await f.users.findByUsername('target-user');
|
||||
const session = await f.sessions.createSession(user!);
|
||||
expect((await f.post('auth.logout', { sessionToken: session.sessionToken })).status).toBe(200);
|
||||
expect(await f.sessions.getSession(session.sessionToken)).toBeNull();
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 123, credential: credential('correct-password') })).status
|
||||
).toBe(503);
|
||||
const malformed = await fetch(`${f.address}/trpc/auth.login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{',
|
||||
});
|
||||
expect(malformed.status).toBe(400);
|
||||
} finally {
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('bounds parallel credential work and admits a subsequent request after all failures settle', async () => {
|
||||
const f = await fixture();
|
||||
let release!: () => void;
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
f.verify.mockImplementation(async () => {
|
||||
await waiting;
|
||||
return false;
|
||||
});
|
||||
const input = { username: 'target-user', credential: credential('wrong-password') };
|
||||
const pending = Array.from({ length: 4 }, () => f.post('auth.login', input));
|
||||
try {
|
||||
await vi.waitFor(() => expect(f.verify).toHaveBeenCalledTimes(4));
|
||||
expect((await f.post('auth.login', input)).status).toBe(429);
|
||||
expect(f.open).toHaveBeenCalledTimes(4);
|
||||
release();
|
||||
expect((await Promise.all(pending)).map((r) => r.status)).toEqual([401, 401, 401, 401]);
|
||||
f.verify.mockRestore();
|
||||
expect(
|
||||
(await f.post('auth.login', { username: 'target-user', credential: credential('correct-password') }))
|
||||
.status
|
||||
).toBe(200);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.allSettled(pending);
|
||||
await f.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,16 @@ describe('Kakao OAuth HTTP transport', () => {
|
||||
await expect(createClient().signup('access-token')).resolves.toEqual({
|
||||
alreadyRegistered: true,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(new URL('https://kapi.example.test/v1/user/signup'), {
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL('https://kapi.example.test/v1/user/signup'),
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: 'Bearer access-token',
|
||||
},
|
||||
redirect: 'error',
|
||||
signal: expect.any(AbortSignal),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('continues to reject unrelated Kakao signup errors', async () => {
|
||||
@@ -40,8 +45,6 @@ describe('Kakao OAuth HTTP transport', () => {
|
||||
})
|
||||
);
|
||||
|
||||
await expect(createClient().signup('access-token')).rejects.toThrow(
|
||||
'Kakao signup error: {"msg":"invalid request","code":-201}'
|
||||
);
|
||||
await expect(createClient().signup('access-token')).rejects.toThrow('Kakao signup error.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { KakaoOAuthClient } from '../src/auth/kakaoClient.js';
|
||||
|
||||
describe('bounded Kakao provider transport', () => {
|
||||
const servers: Server[] = [];
|
||||
afterEach(async () => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
const provider = async (respond: (response: ServerResponse) => void, requestTimeoutMs = 1000) => {
|
||||
const server = createServer((_request, response) => respond(response));
|
||||
servers.push(server);
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Fixture provider listener failed.');
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
return new KakaoOAuthClient({
|
||||
restKey: 'synthetic-key',
|
||||
redirectUri: `${origin}/callback`,
|
||||
apiHost: origin,
|
||||
oauthHost: origin,
|
||||
requestTimeoutMs,
|
||||
});
|
||||
};
|
||||
|
||||
it('retains successful token/account responses and documented recovery responses', async () => {
|
||||
const client = await provider((res) =>
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 42,
|
||||
access_token: 'synthetic-token',
|
||||
expires_in: 3600,
|
||||
kakao_account: {
|
||||
has_email: true,
|
||||
email: 'fixture@example.test',
|
||||
is_email_valid: true,
|
||||
is_email_verified: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(await client.exchangeCode('synthetic-code')).toMatchObject({
|
||||
accessToken: 'synthetic-token',
|
||||
accessTokenExpiresIn: 3600,
|
||||
});
|
||||
expect(await client.getMe('synthetic-token')).toMatchObject({
|
||||
id: '42',
|
||||
kakaoAccount: { email: 'fixture@example.test', isEmailVerified: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts both header and response-body stalls within the configured deadline', async () => {
|
||||
const silent = await provider(() => {}, 100);
|
||||
await expect(silent.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
const partial = await provider((res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.write('{');
|
||||
}, 100);
|
||||
await expect(partial.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
});
|
||||
|
||||
it('limits decompressed response bytes and rejects malformed JSON without exposing payloads', async () => {
|
||||
const oversized = await provider((res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json', 'content-encoding': 'gzip' });
|
||||
res.end(gzipSync(JSON.stringify({ payload: 'x'.repeat(2 * 1024 * 1024) })));
|
||||
});
|
||||
await expect(oversized.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
const malformed = await provider((res) => res.end('synthetic-sensitive-provider-data'));
|
||||
await expect(malformed.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
});
|
||||
|
||||
it('does not follow redirects or expose provider error details', async () => {
|
||||
let destinationRequests = 0;
|
||||
const destination = await provider((res) => {
|
||||
destinationRequests += 1;
|
||||
res.end('{}');
|
||||
});
|
||||
const destinationUrl = new URL(destination.buildAuthUrl('state', []));
|
||||
const redirect = await provider((res) => {
|
||||
res.writeHead(302, { location: destinationUrl.origin });
|
||||
res.end();
|
||||
});
|
||||
await expect(redirect.getMe('synthetic-token')).rejects.toThrow('Kakao HTTP request failed.');
|
||||
expect(destinationRequests).toBe(0);
|
||||
const failure = await provider((res) => {
|
||||
res.writeHead(400);
|
||||
res.end(JSON.stringify({ msg: 'synthetic-provider-secret', code: -201 }));
|
||||
});
|
||||
await expect(failure.signup('synthetic-token')).rejects.toThrow('Kakao signup error.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user