From beaa2fc423ee68f17f711fa6d692346bc323f744 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 2 Nov 2026 14:18:23 +0900 Subject: [PATCH] feat: add account menu and redirect after Q&A logout Add the top-right account icon with the signed-in name and logout link. Expire the Q&A session before redirecting to the configured inquiry homepage in both environments. Preserve login return URL checks and ignore request-controlled logout destinations. Validation: 106 tests, syntax checks, and mocked Chromium desktop/mobile regressions pass, including session-cookie removal and the destination navigation request with external networking blocked. --- README.md | 4 +- assets/inquiry/inquiry.css | 12 ++++++ docs/qa-inquiry-ui-redesign-tasks.md | 13 ++++++- docs/qa-login-to-save-transition.md | 6 ++- docs/qa-production-deployment-checklist.md | 4 +- docs/qa-production-pipeline.md | 2 +- egbim/app.js | 12 ++++++ egbim/index.html | 13 ++++++- src/index.js | 9 ++++- tests/browser/inquiry-ui.mjs | 25 ++++++++++++ tests/worker-logout.test.js | 44 ++++++++++++++++++++++ wrangler.production.toml | 2 +- wrangler.toml | 2 +- 13 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 tests/worker-logout.test.js diff --git a/README.md b/README.md index 2506dad..2dcdc8a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # EG-BIM Q&A static pages -`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다. +`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다. 상단 오른쪽 사람 아이콘의 계정 메뉴에서 로그인 사용자 이름을 확인하고 로그아웃할 수 있습니다. EG-BIM 탭만 가운데 표시하며 TOVA·GAIA는 오픈 전까지 숨깁니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다. @@ -78,6 +78,8 @@ npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다. +로그아웃 메뉴는 `/auth/logout`으로 이동해 해당 환경의 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 돌아갑니다. 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`에 같은 목적지를 설정합니다. 목적지는 서버 배포 설정에서만 읽으며 요청 쿼리로 변경할 수 없습니다. 중앙 SSO의 로그인 종료 요청은 별도로 보내지 않습니다. + ## Gitea Actions 등록값 운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다. diff --git a/assets/inquiry/inquiry.css b/assets/inquiry/inquiry.css index c8f0878..b2e4b36 100644 --- a/assets/inquiry/inquiry.css +++ b/assets/inquiry/inquiry.css @@ -8,6 +8,17 @@ .inquiry-hero__copy { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; text-align: center; padding: 40px 24px; } .inquiry-hero h1 { font-size: 70px; font-weight: 700; line-height: 1.4; letter-spacing: -0.04em; } .inquiry-hero p { font-size: 22px; line-height: 1.6; } +.inquiry-account { position: absolute; top: 24px; right: 32px; z-index: 3; color: #fff; } +.inquiry-account summary { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid #ffffff99; border-radius: 50%; background: #0b274366; cursor: pointer; list-style: none; } +.inquiry-account summary::-webkit-details-marker { display: none; } +.inquiry-account summary:hover, .inquiry-account[open] summary { background: #0b2743b3; } +.inquiry-account summary:focus-visible { outline-color: #fff; } +.inquiry-account svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } +.inquiry-account__panel { position: absolute; top: 54px; right: 0; width: 192px; padding: 8px; border: 1px solid #e5e9ef; border-radius: 10px; background: #fff; color: #26374f; box-shadow: 0 8px 28px #0003; } +.inquiry-hero .inquiry-account__panel p { overflow: hidden; padding: 10px 12px; color: #697386; font-size: 13px; white-space: nowrap; text-overflow: ellipsis; } +.inquiry-account__panel a { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 10px 12px; border-radius: 6px; font-size: 14px; } +.inquiry-account__panel a:hover { background: #edf3fd; color: #164b91; } +.inquiry-account__panel a svg { width: 18px; height: 18px; } .inquiry-workspace { position: relative; z-index: 1; width: min(1248px, 100%); padding: 0 24px; margin: -98px auto 120px; } .inquiry-tabs-shell { --inquiry-tab-curve: 28px; padding: 19px 32px 0; border-radius: 48px 48px 0 0; background: linear-gradient(180deg, #174b64a6, #082f43d9); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: inset 0 1px 0 #74c7f4b3, inset 0 0 0 1px #5fadd666, inset 0 8px 16px #ffffff12, 0 -18px 12px #0a2f4843; } .inquiry-tabs { display: flex; justify-content: center; gap: 60px; position: relative; isolation: isolate; } @@ -116,6 +127,7 @@ html.inquiry-modal-open body { overflow: hidden !important; } .inquiry-hero { --inquiry-hero-shade: rgba(5, 24, 44, 0.38); height: 340px; background-position: 22% center; } .inquiry-hero h1 { font-size: 48px; } .inquiry-hero p { font-size: 17px; } + .inquiry-account { top: 16px; right: 16px; } .inquiry-workspace { margin: -96px auto 72px; padding: 0 16px; } .inquiry-tabs-shell { --inquiry-tab-curve: 13px; padding: 12px 13px 0; border-radius: 30px 30px 0 0; } .inquiry-tabs { gap: 5px; } diff --git a/docs/qa-inquiry-ui-redesign-tasks.md b/docs/qa-inquiry-ui-redesign-tasks.md index 1832b71..4cc3083 100644 --- a/docs/qa-inquiry-ui-redesign-tasks.md +++ b/docs/qa-inquiry-ui-redesign-tasks.md @@ -228,7 +228,7 @@ flowchart LR ## 10. 구현·검증 결과 -변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다. +초기 목록·모달 UI 변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. 해당 작업에서 Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다. - `npm run check`, `npm test`: 기존 94개와 구형 주소 연결 4개, 총 98개 테스트 통과. - R2 업로드 검사: 새 하위 경로의 진입 JS와 WebP 이미지가 기존 배포 경로에 포함되며 MIME은 각각 `application/javascript`, `image/webp`다. @@ -254,4 +254,13 @@ CHROMIUM_EXECUTABLE_PATH=/absolute/path/to/chromium \ npm run test:ui ``` -기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다. +기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장과 모바일 계정 메뉴 화면을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다. + + +## 11. 후속 계정 메뉴 + +- 상단 오른쪽 사람 아이콘으로 로그인 사용자 이름·로그아웃 메뉴를 연다. 바깥 클릭·ESC로 닫는다. +- 로그아웃은 기존 `/auth/logout`에서 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다. +- 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`를 위 주소로 설정했다. 서버 설정의 HTTPS 목적지로 이동할 수 있게 기존 로그아웃 처리를 조정했으며 로그인 `return_url` 정책은 유지한다. +- 중앙 SSO 세션 종료 API는 호출하지 않는다. 추가 Secret·SSO client/callback 변경은 없다. +- 검증: 스테이징/운영 세션 쿠키 삭제·고정 목적지·쿼리 덮어쓰기 방지·잘못된 설정 처리 테스트, 브라우저 메뉴/키보드/모바일 표시 및 실제 Worker 핸들러를 통한 쿠키 삭제·목적지 이동 요청을 확인한다. 리다이렉트 목적지로의 실제 외부 접속은 Chromium 네트워크 차단으로 막는다. 기본 테스트 총 106개와 브라우저 회귀 검사를 통과했다. diff --git a/docs/qa-login-to-save-transition.md b/docs/qa-login-to-save-transition.md index 01c29a5..92e76fa 100644 --- a/docs/qa-login-to-save-transition.md +++ b/docs/qa-login-to-save-transition.md @@ -1,6 +1,6 @@ # EG-BIM Q&A 로그인·화면·저장 흐름 -기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 이번 UI 개편은 프론트에 한정하며 Worker API·DB·SSO 설정을 변경하지 않는다. +기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 목록·모달 UI는 기존 API·DB·SSO 로그인 흐름을 유지한다. 후속 계정 메뉴 작업에서는 기존 로그아웃 경로의 이동 목적지를 홈페이지 문의 페이지로 연결했다. ## 로그인과 화면 진입 @@ -36,6 +36,10 @@ flowchart TD M -->|아니오| O[모달 오류 표시·입력과 파일 유지] ``` +## 계정 메뉴와 로그아웃 + +상단 오른쪽 사람 아이콘을 누르면 로그인 사용자 이름과 로그아웃 링크가 나온다. 바깥 클릭이나 ESC로 메뉴를 닫을 수 있다. `/auth/logout`은 현재 환경의 Q&A 세션 쿠키를 만료시키고 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 302 이동한다. 목적지는 스테이징·운영 Wrangler 설정의 `AUTH_POST_LOGOUT_REDIRECT_URI`에서 읽으며 사용자 쿼리로 덮어쓸 수 없다. 중앙 SSO 세션 종료 API는 호출하지 않는다. + ## 작성·첨부 저장 브라우저는 기존 UUID v7 생성 방식으로 `feedbackId`를 만들고 아래 필드를 보낸다. 회사·부서·작성자·이메일 칸은 읽기 전용이며, 서버가 기존 세션·조직 조회 결과에서 작성자 정보를 결정한다. diff --git a/docs/qa-production-deployment-checklist.md b/docs/qa-production-deployment-checklist.md index ccca734..714659c 100644 --- a/docs/qa-production-deployment-checklist.md +++ b/docs/qa-production-deployment-checklist.md @@ -23,7 +23,7 @@ ### 2. SSO 운영 등록 - [x] 운영 callback URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/auth/callback`. 사용자 완료 보고 기준이다. -- [x] 로그아웃 후 이동 URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/`. 사용자 완료 보고 기준이다. +- [x] 초기 SSO 등록의 로그아웃 후 URI는 `https://qa.baroncs.co.kr/`로 등록했다. 사용자 완료 보고 기준이다. 현재 계정 메뉴의 Q&A 로그아웃은 Worker에서 세션을 삭제하고 별도 설정된 홈페이지 문의 주소로 직접 이동한다. - [x] 운영 SSO 설정에 맞는 `PROD_AUTH_CLIENT_ID` 등 관련 변수 등록을 완료했다. 실제 로그인 동작 확인은 운영 배포 후 수행한다. - [ ] 운영 도메인에서 실제 사내 사용자로 로그인·로그아웃할 수 있는지 확인한다. @@ -56,7 +56,7 @@ | Worker 이름 | `baron-qa-gateway-prod` | | Worker custom domain | `qa.baroncs.co.kr` | | AUTH redirect URI | `https://qa.baroncs.co.kr/auth/callback` | -| AUTH post logout URI | `https://qa.baroncs.co.kr/` | +| Q&A 로그아웃 후 이동 URI | `https://test.baroncs.co.kr/ko/sw-inquiry.html` (`AUTH_POST_LOGOUT_REDIRECT_URI`) | | AUTH client ID | 운영 SSO client ID | | ABC API base URL | `https://feedback.hmac.kr` 또는 운영 API 주소 | | ABC project ID / channel ID | 운영 대상 ID | diff --git a/docs/qa-production-pipeline.md b/docs/qa-production-pipeline.md index a240470..23dc850 100644 --- a/docs/qa-production-pipeline.md +++ b/docs/qa-production-pipeline.md @@ -61,7 +61,7 @@ R2 권한은 계정 범위의 **Workers R2 Storage Write/Edit**를 선택한다. | `PROD_ORG_CONTEXT_EXPOSURE` | 선택 | 기본 `identifiers` | | `PROD_ORG_CONTEXT_CACHE_TTL_SECONDS` | 선택 | 기본 `300`, 허용 범위 1~3600 | -SSO에는 `https://qa.baroncs.co.kr/auth/callback`을 허용하고, 로그아웃 후 이동 URI로 `https://qa.baroncs.co.kr/`을 확인한다. 등록된 SSO client나 ABC project/channel이 운영 대상인지 확정한 후 배포한다. +SSO에는 `https://qa.baroncs.co.kr/auth/callback`을 허용한다. Q&A 로그아웃은 Worker가 세션 쿠키를 삭제한 뒤 `AUTH_POST_LOGOUT_REDIRECT_URI`에 설정된 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다. 이 이동은 중앙 SSO 로그아웃 API를 사용하지 않는다. 등록된 SSO client나 ABC project/channel이 운영 대상인지 확정한 후 배포한다. 지금 준비할 Variables는 `PROD_AUTH_CLIENT_ID`와 `PROD_ORG_CONTEXT_*` 6개다. `PROD_AUTH_CLIENT_ID`의 참고값은 기존 스테이징 client `e16898a7-acd2-420a-a224-cdcd89ff66cd`이며, 이 client에 운영 callback이 허용된 경우에만 운영에서도 재사용한다. 별도 운영 client를 만들었다면 그 ID를 등록한다. 기준 tenant UUID와 조직 연동 키는 실제 발급받은 값을 사용한다. 기존 `STAGING_*` 등록은 `PROD_*` 등록으로 간주하지 않는다. diff --git a/egbim/app.js b/egbim/app.js index a669933..709901a 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -501,6 +501,18 @@ console.error('[QA] SSO session load failed:', error); return getAuthUser(); }).then(function (user) { renderAuth(user); return user; }); + const accountMenu = qs('#accountMenu'); + if (accountMenu) { + document.addEventListener('click', function (event) { + if (!accountMenu.contains(event.target)) accountMenu.open = false; + }); + document.addEventListener('keydown', function (event) { + if (event.key === 'Escape' && accountMenu.open) { + accountMenu.open = false; + qs('#accountButton').focus(); + } + }); + } const menuButton = qs('.menu-button'); const nav = qs('.global-nav'); if (menuButton && nav) { diff --git a/egbim/index.html b/egbim/index.html index 90a943f..d758278 100644 --- a/egbim/index.html +++ b/egbim/index.html @@ -1,7 +1,16 @@ -S/W 문의 | 바론컨설턴트 +S/W 문의 | 바론컨설턴트
+

S/W 문의

소프트웨어 관련 문의하기

@@ -204,5 +213,5 @@
-
+
diff --git a/src/index.js b/src/index.js index 5566f87..fcd07ca 100644 --- a/src/index.js +++ b/src/index.js @@ -766,6 +766,11 @@ function isUuidv7(value) { function logout(request, env) { const url = new URL(request.url); - const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin); - return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) }); + let target = new URL('/', url.origin); + try { + // External destinations come only from trusted deployment config, never request parameters. + const configured = new URL(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin); + if ((configured.protocol === 'https:' || configured.origin === url.origin) && !configured.username && !configured.password) target = configured; + } catch (error) {} + return redirect(target.toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) }); } diff --git a/tests/browser/inquiry-ui.mjs b/tests/browser/inquiry-ui.mjs index a46cb3d..403ac1a 100644 --- a/tests/browser/inquiry-ui.mjs +++ b/tests/browser/inquiry-ui.mjs @@ -6,6 +6,7 @@ import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; import {join} from 'node:path'; import {tmpdir} from 'node:os'; +import worker from '../../src/index.js'; const require = createRequire(import.meta.url); const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); const root = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, ''); @@ -17,7 +18,12 @@ await mkdir(images,{recursive:true}); const errors = []; try { const context = await browser.newContext({viewport:{width:1900,height:1000}}); + await context.addCookies([{name:'fixture_session',value:'existing-session',url:'https://qa.example.test',httpOnly:true,secure:true}]); const page = await context.newPage();page.setDefaultTimeout(8000);page.setDefaultNavigationTimeout(8000); + // Redirect continuations bypass Playwright route handlers; block the real destinations in Chromium. + const network = await context.newCDPSession(page); + await network.send('Network.enable'); + await network.send('Network.setBlockedURLs',{urls:['https://test.baroncs.co.kr/*','https://sso.hmac.kr/*']}); page.on('pageerror', error => errors.push(error.message)); const records = Array.from({length:15},(_,i)=>({id:id(i+1),requester_id:OWNER,requester_name:'송대일',requester_affiliation:'총괄기획&기술개발센터',requester_department:'IS3',title:['PDF 출력 시 이미지 표시 관련 문의','EG-BIM 로그인 오류 문의','모델링 기능 개선 요청'][i%3] + (i>2?' '+(i+1):''),contents:'EG-BIM 이용 중 궁금한 사항을 문의합니다.\n\n이 영역에 문의 내용과 첨부파일, 답변이 표시됩니다.',Category:['ERROR_QNA','GENERAL_QNA','IMPROVEMENT_QNA'][i%3],created_at:'2026-11-02T01:14:00Z',feedback_status:['NEW','RECEIVED','IN_PROGRESS','PENDING','COMPLETED'][i%5],is_secret:i===0,attachments:i===0?[{url:'/fixture/manual.pdf',original_file_name:'화면 설명.pdf'}]:[]})); const comments = new Map(records.map(r=>[r.id,[{id:id(101),author_id:OWNER,author_name:'송대일',content:'확인 부탁드립니다.',created_at:'2026-11-02T02:00:00Z'},{id:id(102),author_id:'other',author_name:'고객지원',content:'접수되었습니다.',created_at:'2026-11-02T02:15:00Z'}]])); @@ -26,6 +32,10 @@ try { await page.route('**/*',async route=>{ const request = route.request(); const url = new URL(request.url()); if(url.origin!=='https://qa.example.test') return route.abort(); + if(url.pathname==='/auth/logout') { + const response=await worker.fetch(new Request(url),{SESSION_COOKIE_NAME:'fixture_session',AUTH_POST_LOGOUT_REDIRECT_URI:'https://test.baroncs.co.kr/ko/sw-inquiry.html'}); + return route.fulfill({status:response.status,headers:Object.fromEntries(response.headers),body:''}); + } const send = (body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:OWNER,userUuid:OWNER,tenantId:id(555),name:'송대일',email:'fixture@example.test',company:'총괄기획&기술개발센터',department:'IS3'}}); if(url.pathname.startsWith('/api/')) { @@ -65,6 +75,14 @@ try { await page.waitForFunction(()=>document.querySelector('#resultCount').textContent==='총 15건'); assert.equal(await page.locator('#tab-tova').isVisible(),false);assert.equal(await page.locator('#tab-gaia').isVisible(),false); assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/'); + await page.locator('#accountButton').click(); + assert.equal(await page.locator('#logoutLink').isVisible(),true); + assert.equal(await page.locator('#accountPanel [data-auth-name]').textContent(),'송대일'); + await page.keyboard.press('Escape'); + assert.equal(await page.locator('#logoutLink').isVisible(),false); + assert.equal(await page.locator('#accountButton').evaluate(e=>e===document.activeElement),true); + await page.locator('#accountButton').click();await page.locator('#inquiry-title').click(); + assert.equal(await page.locator('#logoutLink').isVisible(),false); await page.screenshot({path:images+'/desktop-list.png',fullPage:true}); await page.locator('#query').fill('로그임');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());assert.equal(await page.locator('#qaRows tr').count(),5); await page.locator('#qaRows tr a').first().click();await page.waitForSelector('#detailModal[open]');await close();assert.equal(await page.locator('#query').inputValue(),'로그임'); @@ -117,6 +135,13 @@ try { assert.equal(await page.locator('#editButton').isVisible(),false); assert.equal(await page.locator('#commentForm').isVisible(),false); await close(); + await page.locator('#accountButton').click(); + assert.equal(await page.locator('#accountPanel').evaluate(e=>e.getBoundingClientRect().right<=innerWidth&&e.getBoundingClientRect().left>=0),true); + await page.screenshot({path:images+'/mobile-account.png'}); + const logoutDestination = page.waitForRequest('https://test.baroncs.co.kr/ko/sw-inquiry.html'); + await page.locator('#logoutLink').click({noWaitAfter:true}); + await logoutDestination; + assert.equal((await context.cookies('https://qa.example.test')).some(c=>c.name==='fixture_session'),false); assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'PASS',mutations:mutations.length,screenshots:images}));await context.close(); for (const scenario of ['owner', 'other', 'same-name-no-id', 'anonymous-with-stale-user', 'session-unavailable', 'delayed-session']) { const context = await browser.newContext(); diff --git a/tests/worker-logout.test.js b/tests/worker-logout.test.js new file mode 100644 index 0000000..1f6e0d7 --- /dev/null +++ b/tests/worker-logout.test.js @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import worker from '../src/index.js'; + +const destination = 'https://test.baroncs.co.kr/ko/sw-inquiry.html'; + +for (const configFile of ['wrangler.toml', 'wrangler.production.toml']) { + test(configFile + ' logout expires its own session and redirects to the inquiry homepage', async () => { + const config = readFileSync(new URL('../' + configFile, import.meta.url), 'utf8'); + const setting = name => config.match(new RegExp('^' + name + ' = "([^"]+)"', 'm'))[1]; + const env = { + SESSION_COOKIE_NAME: setting('SESSION_COOKIE_NAME'), + AUTH_POST_LOGOUT_REDIRECT_URI: setting('AUTH_POST_LOGOUT_REDIRECT_URI') + }; + const response = await worker.fetch(new Request('https://qa.example.test/auth/logout?return_url=https://untrusted.example/&redirect_uri=https://untrusted.example/', { + headers: { cookie: env.SESSION_COOKIE_NAME + '=existing-session' } + }), env); + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), destination); + const cookie = response.headers.get('set-cookie'); + assert.ok(cookie.startsWith(env.SESSION_COOKIE_NAME + '=;')); + for (const attribute of ['Max-Age=0', 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax', 'Expires=Thu, 01 Jan 1970 00:00:00 GMT']) assert.ok(cookie.includes(attribute)); + assert.match(response.headers.get('cache-control'), /no-store/); + }); +} + +for (const [label, value, expected] of [ + ['no configured destination', undefined, '/'], + ['same-origin relative destination', '/signed-out', '/signed-out'], + ['invalid URL', 'https://[invalid', '/'], + ['script URL', 'javascript:alert(1)', '/'], + ['external insecure URL', 'http://untrusted.example/', '/'], + ['URL containing credentials', 'https://user:pass@untrusted.example/', '/'] +]) { + test('logout handles ' + label + ' while clearing the session', async () => { + const response = await worker.fetch(new Request('https://qa.example.test/auth/logout'), { + SESSION_COOKIE_NAME: 'fixture_session', AUTH_POST_LOGOUT_REDIRECT_URI: value + }); + assert.equal(response.status, 302); + assert.equal(response.headers.get('location'), 'https://qa.example.test' + expected); + assert.match(response.headers.get('set-cookie'), /fixture_session=; Max-Age=0/); + }); +} diff --git a/wrangler.production.toml b/wrangler.production.toml index 423ef00..120e174 100644 --- a/wrangler.production.toml +++ b/wrangler.production.toml @@ -10,7 +10,7 @@ preview_urls = false [vars] AUTH_REDIRECT_URI = "https://qa.baroncs.co.kr/auth/callback" -AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa.baroncs.co.kr/" +AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html" AUTH_SCOPE = "openid tenants profile email" AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth" AUTH_TOKEN_URL = "https://sso.hmac.kr/oidc/oauth2/token" diff --git a/wrangler.toml b/wrangler.toml index 5938abd..a690e75 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -10,7 +10,7 @@ routes = [ [vars] AUTH_REDIRECT_URI = "https://qa-test.baroncs.co.kr/auth/callback" -AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa-test.baroncs.co.kr/" +AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html" AUTH_SCOPE = "openid tenants profile email" AUTH_CLIENT_ID = "e16898a7-acd2-420a-a224-cdcd89ff66cd" AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth"