Django(OpenCode 중계) 백엔드는 토큰을 쿠키가 아니라 응답 body 로 주고, Tauri 앱은
서버와 origin 이 달라 쿠키가 안 붙는다. 기존 tokenProvider seam 에 refresh 토큰과
localStorage 영속을 얹고, client/sse 의 refresh 를 body {refreshToken} 방식으로 바꿈.
로그인 때 저장하고 로그아웃 때 비움. 토큰 없으면 예전 쿠키 모드 그대로.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
273 lines
9.2 KiB
TypeScript
273 lines
9.2 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
import axios from "axios"
|
|
import MockAdapter from "axios-mock-adapter"
|
|
import { apiClient, apiGet, apiPost, apiList } from "./client"
|
|
import { ApiError } from "./errors"
|
|
import { useSessionExpiryStore } from "@/features/auth/store/sessionExpiryStore"
|
|
import { setAccessToken, setTokens, clearTokens, getAccessToken } from "@/lib/auth/tokenProvider"
|
|
|
|
const BASE = "http://localhost:8001/api/v1"
|
|
|
|
function envelope<T>(data: T, meta: unknown = null, counts: number | null = null) {
|
|
return {
|
|
success: true,
|
|
statusCode: 200,
|
|
code: null,
|
|
message: null,
|
|
data,
|
|
counts,
|
|
errors: [] as string[],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
meta,
|
|
}
|
|
}
|
|
|
|
function errorEnvelope(opts: {
|
|
statusCode: number
|
|
message: string
|
|
code?: string
|
|
errors?: string[]
|
|
}) {
|
|
return {
|
|
success: false,
|
|
statusCode: opts.statusCode,
|
|
code: opts.code ?? null,
|
|
message: opts.message,
|
|
data: null,
|
|
counts: null,
|
|
errors: opts.errors ?? [opts.message],
|
|
timestamp: "2026-01-01T00:00:00Z",
|
|
meta: null,
|
|
}
|
|
}
|
|
|
|
let clientMock: MockAdapter
|
|
let globalMock: MockAdapter
|
|
|
|
beforeEach(() => {
|
|
clientMock = new MockAdapter(apiClient)
|
|
// refresh는 글로벌 axios.post로 호출 (인터셉터 우회 위해)
|
|
globalMock = new MockAdapter(axios)
|
|
useSessionExpiryStore.setState({ open: false, queue: [] })
|
|
})
|
|
|
|
afterEach(() => {
|
|
clientMock.restore()
|
|
globalMock.restore()
|
|
})
|
|
|
|
describe("apiGet / apiPost / unwrap", () => {
|
|
it("성공 envelope이면 data만 반환", async () => {
|
|
clientMock.onGet("/users/me").reply(200, envelope({ id: "u1", email: "x@x.com" }))
|
|
const result = await apiGet<{ id: string; email: string }>("/users/me")
|
|
expect(result).toEqual({ id: "u1", email: "x@x.com" })
|
|
})
|
|
|
|
it("withCredentials=true 가 인스턴스 default로 박힘", async () => {
|
|
expect(apiClient.defaults.withCredentials).toBe(true)
|
|
})
|
|
|
|
it("baseURL 이 env 값으로 설정됨", () => {
|
|
expect(apiClient.defaults.baseURL).toBe(BASE)
|
|
})
|
|
|
|
it("apiPost: body 직렬화 + 응답 unwrap", async () => {
|
|
clientMock.onPost("/users", { email: "x@x.com" }).reply(200, envelope({ id: 1 }))
|
|
const result = await apiPost<{ id: number }>("/users", { email: "x@x.com" })
|
|
expect(result).toEqual({ id: 1 })
|
|
})
|
|
|
|
it("응답 envelope success=false 면 ApiError reject (메시지·code·errors 추출)", async () => {
|
|
clientMock.onGet("/missing").reply(
|
|
404,
|
|
errorEnvelope({
|
|
statusCode: 404,
|
|
message: "메모를 찾을 수 없음",
|
|
code: "MEMO_NOT_FOUND",
|
|
})
|
|
)
|
|
await expect(apiGet("/missing")).rejects.toMatchObject({
|
|
name: "ApiError",
|
|
status: 404,
|
|
message: "메모를 찾을 수 없음",
|
|
code: "MEMO_NOT_FOUND",
|
|
})
|
|
})
|
|
|
|
it("200 응답에 envelope.success=false 박혀와도 ApiError reject", async () => {
|
|
clientMock
|
|
.onGet("/weird")
|
|
.reply(200, errorEnvelope({ statusCode: 400, message: "validation 실패" }))
|
|
await expect(apiGet("/weird")).rejects.toBeInstanceOf(ApiError)
|
|
})
|
|
})
|
|
|
|
describe("apiList", () => {
|
|
it("envelope에서 items / meta / counts 추출", async () => {
|
|
const meta = {
|
|
currentPage: 1,
|
|
pageSize: 10,
|
|
totalItems: 25,
|
|
totalPages: 3,
|
|
hasNextPage: true,
|
|
hasPreviousPage: false,
|
|
}
|
|
clientMock.onGet("/users").reply(200, envelope([{ id: "a" }, { id: "b" }], meta, 2))
|
|
|
|
const result = await apiList<{ id: string }>("/users")
|
|
expect(result.items).toEqual([{ id: "a" }, { id: "b" }])
|
|
expect(result.meta).toEqual(meta)
|
|
expect(result.counts).toBe(2)
|
|
})
|
|
|
|
it("data가 null이면 items 빈 배열로 fallback", async () => {
|
|
clientMock.onGet("/users").reply(200, envelope(null))
|
|
const result = await apiList<{ id: string }>("/users")
|
|
expect(result.items).toEqual([])
|
|
expect(result.counts).toBe(0)
|
|
})
|
|
|
|
it("query params 전달", async () => {
|
|
clientMock.onGet("/users").reply((config) => {
|
|
expect(config.params).toEqual({ page: 2, search: "abc" })
|
|
return [200, envelope([], null, 0)]
|
|
})
|
|
await apiList<{ id: string }>("/users", { params: { page: 2, search: "abc" } })
|
|
})
|
|
})
|
|
|
|
describe("401 인터셉터 — refresh 성공", () => {
|
|
it("401 → refresh 호출 → 원 요청 재시도", async () => {
|
|
clientMock
|
|
.onGet("/users/me")
|
|
.replyOnce(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
|
.onGet("/users/me")
|
|
.replyOnce(200, envelope({ id: "u1" }))
|
|
|
|
globalMock.onPost(`${BASE}/auth/refresh`).reply(200, envelope({ token: "new" }))
|
|
|
|
const result = await apiGet<{ id: string }>("/users/me")
|
|
expect(result).toEqual({ id: "u1" })
|
|
expect(globalMock.history.post.some((r) => r.url?.endsWith("/auth/refresh"))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe("401 인터셉터 — Bearer 모드 refresh", () => {
|
|
afterEach(() => clearTokens())
|
|
|
|
it("refreshToken 있으면 body 로 보내고, 응답 토큰을 저장한 뒤 새 Bearer 로 재시도", async () => {
|
|
setTokens({ token: "old", refreshToken: "ref" })
|
|
const authHeaders: string[] = []
|
|
clientMock.onGet("/users/me").reply((config) => {
|
|
authHeaders.push(String(config.headers?.Authorization))
|
|
return authHeaders.length === 1
|
|
? [401, errorEnvelope({ statusCode: 401, message: "expired" })]
|
|
: [200, envelope({ id: "u1" })]
|
|
})
|
|
globalMock
|
|
.onPost(`${BASE}/auth/refresh`)
|
|
.reply(200, envelope({ token: "new", refreshToken: "ref", tokenExpirationTime: 1 }))
|
|
|
|
await apiGet<{ id: string }>("/users/me")
|
|
|
|
const refreshReq = globalMock.history.post.find((r) => r.url?.endsWith("/auth/refresh"))
|
|
expect(JSON.parse(refreshReq?.data as string)).toEqual({ refreshToken: "ref" })
|
|
expect(getAccessToken()).toBe("new")
|
|
expect(authHeaders).toEqual(["Bearer old", "Bearer new"])
|
|
})
|
|
})
|
|
|
|
describe("401 인터셉터 — refresh 실패", () => {
|
|
it("refresh가 401이면 sessionExpiryStore에 큐 push + open=true", async () => {
|
|
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "expired" }))
|
|
globalMock
|
|
.onPost(`${BASE}/auth/refresh`)
|
|
.reply(401, errorEnvelope({ statusCode: 401, message: "bad refresh" }))
|
|
|
|
// pushFailure는 retry 함수를 큐에 보관 → resolve/reject는 closeAndFlush 시점.
|
|
// 여기선 promise를 띄워두기만.
|
|
const promise = apiGet("/users/me")
|
|
// 인터셉터 처리 시간 확보
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
|
|
const state = useSessionExpiryStore.getState()
|
|
expect(state.open).toBe(true)
|
|
expect(state.queue.length).toBe(1)
|
|
|
|
// dangling promise 정리 — cancel하면 큐가 폐기됨. 결과는 cancel로 reject되지 않으니 catch만.
|
|
state.cancel()
|
|
promise.catch(() => {})
|
|
})
|
|
})
|
|
|
|
describe("401 인터셉터 — 인증 초기화", () => {
|
|
it("refresh 실패를 세션 만료 큐에 넣지 않고 호출자에게 반환", async () => {
|
|
clientMock.onGet("/users/me").reply(401, errorEnvelope({ statusCode: 401, message: "no auth" }))
|
|
globalMock
|
|
.onPost(`${BASE}/auth/refresh`)
|
|
.reply(401, errorEnvelope({ statusCode: 401, message: "no refresh" }))
|
|
|
|
await expect(apiGet("/users/me", { __skipSessionExpiry: true })).rejects.toBeInstanceOf(
|
|
ApiError
|
|
)
|
|
expect(useSessionExpiryStore.getState().open).toBe(false)
|
|
expect(useSessionExpiryStore.getState().queue).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe("__skipAuth — public 엔드포인트 401 시 모달 안 띄움", () => {
|
|
it("logout 같은 skipAuth 요청은 401에서도 sessionExpiryStore 안 건드림", async () => {
|
|
clientMock
|
|
.onPost("/auth/logout")
|
|
.reply(401, errorEnvelope({ statusCode: 401, message: "no session" }))
|
|
|
|
await expect(apiPost("/auth/logout", undefined, { __skipAuth: true })).rejects.toBeInstanceOf(
|
|
ApiError
|
|
)
|
|
|
|
expect(useSessionExpiryStore.getState().open).toBe(false)
|
|
expect(useSessionExpiryStore.getState().queue.length).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe("apiClient Bearer 주입", () => {
|
|
afterEach(() => {
|
|
setAccessToken(null)
|
|
delete apiClient.defaults.adapter
|
|
})
|
|
|
|
it("토큰 있으면 Authorization: Bearer 헤더를 붙인다", async () => {
|
|
setAccessToken("tok123")
|
|
let seen: unknown
|
|
apiClient.defaults.adapter = async (config) => {
|
|
seen = config.headers.Authorization
|
|
return {
|
|
data: { success: true, data: null },
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: {},
|
|
config,
|
|
} as never
|
|
}
|
|
await apiClient.get("/ping")
|
|
expect(seen).toBe("Bearer tok123")
|
|
})
|
|
|
|
it("토큰 없으면 Authorization 를 안 붙인다(쿠키 모드)", async () => {
|
|
setAccessToken(null)
|
|
let seen: unknown = "sentinel"
|
|
apiClient.defaults.adapter = async (config) => {
|
|
seen = config.headers.Authorization
|
|
return {
|
|
data: { success: true, data: null },
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: {},
|
|
config,
|
|
} as never
|
|
}
|
|
await apiClient.get("/ping")
|
|
expect(seen).toBeUndefined()
|
|
})
|
|
})
|