fix(388-1 · 388-3 보안): 로그아웃 · 계정 바뀜 지움 · 세션 넘침 · 탭 프로젝트 고정

- 로그아웃 = 떠남 확인 먼저 → 서버 로그아웃 → 세션 전부 · 현재 프로젝트 · 3D 지형 지움 → 홈 새로고침
- 계정 바뀜(저장한 주인 ≠ 지금 사용자) = 같은 지움 뒤 새로고침 · 열쇠에 계정 안 섞음
- 세션 한도 넘침 = 결과 칸 비우고 한 번 다시 · 그래도 넘치면 「저장 공간 부족」 안내
- B05 · B06 이 연 프로젝트 고정 — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
- 새 test_384_s_logout_wipe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-17 17:21:38 +09:00
co-authored by Claude Opus 5.5
parent 23c6645839
commit 3e312b68d7
8 changed files with 281 additions and 5 deletions
+128
View File
@@ -0,0 +1,128 @@
/* 388 보안 — 진짜 TS(b_page_state · b_project_switch · design_store)를 가짜 저장소 위에서 돌려
* JSON 으로 냄: 로그아웃 지움 · 계정 바뀜 지움 · 세션 넘침 · 탭이 연 프로젝트 고정. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (rel, requireStub) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
return exports;
};
/** 진짜 Storage 처럼 Object.keys 가 열쇠를 냄 · limit 글자 넘으면 QuotaExceededError. */
function fakeStorage(limit = Infinity) {
const data = {};
const size = () => Object.entries(data).reduce((n, [k, v]) => n + k.length + v.length, 0);
const api = {
length: { get: () => Object.keys(data).length },
key: { value: (i) => Object.keys(data)[i] ?? null },
getItem: { value: (k) => (k in data ? data[k] : null) },
setItem: {
value: (k, v) => {
const old = data[k];
data[k] = String(v);
if (size() > limit) {
if (old === undefined) delete data[k];
else data[k] = old;
const error = new Error("full");
error.name = "QuotaExceededError";
throw error;
}
},
},
removeItem: { value: (k) => delete data[k] },
clear: { value: () => Object.keys(data).forEach((k) => delete data[k]) },
};
return new Proxy(data, {
get: (t, p) => (p in api ? (api[p].get ? api[p].get() : api[p].value) : t[p]),
});
}
global.window = { sessionStorage: fakeStorage(), localStorage: fakeStorage() };
const toasts = [];
const pageState = load("A00_Common/b_page_state.ts", (id) =>
id.includes("ui_template_locale")
? { t: (key) => key }
: { showToast: (msg, kind) => toasts.push([msg, kind]) },
);
const purged = [];
const config = { CURRENT_PROJECT_ID_KEY: "frd_current_project_id" };
const sw = load("A00_Common/b_project_switch.ts", (id) => {
if (id.includes("b_page_state")) return pageState;
if (id.includes("b_asset_cache")) return { purgeOtherProjects: async (p) => purged.push(p) };
if (id.includes("config_frontend")) return config;
return { showConfirmDialog: async () => true };
});
const store = load("A00_Common/design/design_store.ts", (id) =>
id.includes("b_page_state") ? pageState : config,
);
const P = "aaaa1111";
const seed = () => {
global.window.sessionStorage = fakeStorage();
const s = global.window.sessionStorage;
s.setItem(`aislo:draft:structures:${P}`, '[{"id":1}]');
s.setItem(`aislo:result:section-detail:${P}:7`, '{"d":1}');
s.setItem("aislo:entered-project", P);
s.setItem("b05:pipes", "[1]");
global.window.localStorage.setItem("frd_current_project_id", P);
global.window.localStorage.setItem("aislo:pref:panel:x", "1");
purged.length = 0;
};
const sessionKeys = () => Object.keys(global.window.sessionStorage).sort();
const local = (k) => global.window.localStorage.getItem(k);
const tick = () => new Promise((r) => setTimeout(r, 0));
(async () => {
const out = {};
seed();
await sw.wipeAccountCache();
out.logout = {
session: sessionKeys(),
project: local("frd_current_project_id"),
pref: local("aislo:pref:panel:x"),
purged: [...purged],
};
global.window.localStorage.removeItem("aislo:owner");
seed();
out.owner = { first: await sw.wipeIfOwnerChanged("u1"), firstKeys: sessionKeys().length };
seed();
out.owner.same = await sw.wipeIfOwnerChanged("u1");
out.owner.sameKeys = sessionKeys().length;
out.owner.other = await sw.wipeIfOwnerChanged("u2");
out.owner.otherKeys = sessionKeys().length;
out.owner.otherProject = local("frd_current_project_id");
out.owner.stored = local("aislo:owner");
// 세션 넘침 — 결과 칸 비우고 다시 · 그래도 넘치면 안내
global.window.sessionStorage = fakeStorage(400);
const s = global.window.sessionStorage;
s.setItem(`aislo:result:section-detail:${P}:7`, "r".repeat(200));
s.setItem(`aislo:draft:structures:${P}`, "d".repeat(50));
pageState.writeByKey(`aislo:draft:crossw:${P}:7`, "c".repeat(100));
out.overflow = {
saved: s.getItem(`aislo:draft:crossw:${P}:7`)?.length ?? 0,
result: s.getItem(`aislo:result:section-detail:${P}:7`),
draft: s.getItem(`aislo:draft:structures:${P}`)?.length ?? 0,
toasts: toasts.length,
};
pageState.writeByKey(`aislo:draft:crossw:${P}:8`, "c".repeat(500));
await tick();
out.full = { saved: s.getItem(`aislo:draft:crossw:${P}:8`), toasts: [...toasts] };
// 탭이 연 프로젝트 고정 — 다른 탭이 현재 프로젝트를 바꿔도 그대로
global.window.localStorage.setItem("frd_current_project_id", "first");
out.pin = { before: store.draftProjectId() };
store.pinDraftProject("first");
global.window.localStorage.setItem("frd_current_project_id", "other-tab");
out.pin.after = store.draftProjectId();
out.pin.explicit = store.draftProjectId("given");
process.stdout.write(JSON.stringify(out));
})();
@@ -0,0 +1,73 @@
"""388-1 · 388-3 보안 — 로그아웃 · 계정 바뀜 지움 · 세션 넘침 · 탭이 연 프로젝트 고정(요구 242).
- 로그아웃 = 세션 전부(초안 · 결과 · 들어온 표식) · 현재 프로젝트 · 3D 보관함 지움 · pref 둠
- 계정 바뀜(저장한 주인 ≠ 지금 사용자) = 같은 지움 · 같은 계정이면 안 지움 · 열쇠에 계정 안 섞음
- 세션 넘침 = 결과 칸 비우고 한 번 다시 · 그래도 넘치면 「저장 공간 부족」 안내
- 탭이 연 프로젝트 고정 = 다른 탭이 현재 프로젝트를 바꿔도 이 탭 초안 칸 그대로
`helper_384_s_session.cjs` 가 진짜 TS 를 가짜 저장소 위에서 돌림.
"""
import json
import os
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def out():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_384_s_session.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_로그아웃_지움(out):
assert out["logout"]["session"] == []
assert out["logout"]["project"] is None
assert out["logout"]["pref"] == "1"
assert out["logout"]["purged"] == [""] # 빈 id = 모든 프로젝트 지형
def test_계정_바뀜_지움(out):
owner = out["owner"]
assert owner["first"] is True and owner["firstKeys"] == 0 # 주인 모름 = 지움
assert owner["same"] is False and owner["sameKeys"] == 4
assert owner["other"] is True and owner["otherKeys"] == 0
assert owner["otherProject"] is None and owner["stored"] == "u2"
def test_세션_넘침_결과_비우고_다시(out):
assert out["overflow"] == {"saved": 100, "result": None, "draft": 50, "toasts": 0}
def test_그래도_넘치면_안내(out):
assert out["full"]["saved"] is None
assert out["full"]["toasts"] == [["Session_Storage_Full", "warning"]]
def test_탭이_연_프로젝트_고정(out):
assert out["pin"] == {"before": "first", "after": "first", "explicit": "given"}
def test_로그아웃_길_차례():
shell = (ROOT / "A00_Common/app_shell.ts").read_text(encoding="utf-8")
body = shell[shell.index("async function onApp_Logout_Click") :]
body = body[: body.index("\n}\n")]
order = [
body.index(s) for s in ("takeLeaveGuard()", "logout()", "wipeAccountCache()", "reload()")
]
assert order == sorted(order)
login = (ROOT / "A06_Login/A06_Login_Api_Fetch.ts").read_text(encoding="utf-8")
assert "wipeIfOwnerChanged(data.user.id)" in login
page = (ROOT / "A00_Common/design/design_page.ts").read_text(encoding="utf-8")
assert "pinDraftProject(projectId)" in page