Feature/#126 (#193)

* fix(toss-securities): clarify session expiry and quote 403 handling

* Clarify toss empty-output session expiry

Portfolio and watchlist reads can exit successfully with empty payloads when the stored Toss session has expired. The empty-output path now verifies the session before JSON parsing and only promotes confirmed invalid auth doctor data into TossSessionExpiredError.

Constraint: Scope is limited to toss-securities issue #126 follow-up on PR #192

Rejected: Treat auth doctor execution failures as expired sessions | unsupported or failing doctor output is inconclusive without parsed session.valid=false

Confidence: high

Scope-risk: narrow

Directive: Keep empty-result session expiry classification tied to explicit auth doctor confirmation

Tested: npm run test --workspace toss-securities; npm run lint --workspace toss-securities; npm run ci; manual mock tossctl blank stdout invalid/inconclusive doctor checks

* Avoid false session-expiry labels for validation errors

The toss wrapper now treats bare validation_error text as an upstream command failure instead of a session-expired signal. Structured auth doctor JSON remains the source of truth for empty portfolio/watchlist invalid-session promotion, while known stored-session-invalid stderr still maps to TossSessionExpiredError.\n\nConstraint: PR #192 follow-up must stay scoped to issue #126 toss-securities behavior.\nRejected: Keep validation_error in the global regex | it mislabels auth doctor transport failures and quote 403 validation errors as session expiry.\nConfidence: high\nScope-risk: narrow\nDirective: Do not broaden the free-text session classifier without regressions for auth doctor and quote upstream validation failures.\nTested: npm run lint --workspace toss-securities; npm run test --workspace toss-securities; npm run ci; manual mock tossctl validation_error checks; architect verification CLEAR\nNot-tested: Live tossctl network/auth session against real Toss upstream

* Preserve toss empty-response auth-doctor contract

The prior review identified the empty portfolio/watchlist promotion rule as an upstream-contract dependency worth making explicit. Add regression coverage for the non-invalid auth doctor path and document that only parsed JSON with session.valid false promotes empty results to TossSessionExpiredError.

Constraint: Scope is issue #126 / toss-securities only; public-restroom-nearby changes are excluded.
Rejected: Treat any auth doctor output as session-expiry evidence | false positives would relabel valid empty portfolio/watchlist responses.
Confidence: high
Scope-risk: narrow
Directive: Do not broaden empty-response promotion unless tossctl provides a stronger authenticated-empty-result contract.
Tested: npm run lint --workspace toss-securities
Tested: npm run test --workspace toss-securities (15/15)
Tested: npm run ci
Tested: Manual mock tossctl empty portfolio with session.valid true preserved []
Tested: Architect verification CLEAR
Not-tested: Live Toss Securities account session behavior.

---------

Co-authored-by: galvaomica <galvaomica@galvaomicaui-MacBookAir.local>
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-30 19:58:39 +09:00 committed by GitHub
commit 3cea4be6eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 403 additions and 8 deletions

View file

@ -0,0 +1,11 @@
---
"toss-securities": minor
---
Improve toss-securities session-expiry handling and diagnostics.
- Add `auth doctor` wiring and `checkSession()` helper.
- Add `TossSessionExpiredError` for clearer invalid-session failures.
- Promote silent empty-array responses from portfolio/watchlist into explicit session-expired errors when `auth doctor` says session is invalid.
- Add `search/stocks 403` upstream hinting for quote failures.
- Extend tests and README to document behavior and `tossctl >= 0.3.6` recommendation.

View file

@ -6,6 +6,8 @@
먼저 upstream CLI 를 설치합니다.
중요: `tossctl >= 0.3.6` 사용을 권장합니다. (`quote` 403 / 세션 관련 upstream 이슈 #15 반영 버전)
```bash
brew tap JungHoonGhae/tossinvest-cli
brew install tossctl
@ -31,9 +33,16 @@ npm install toss-securities
- `listOrders()`
- `listCompletedOrders({ market })`
- `listWatchlist()`
- `checkSession()`
모든 helper 는 내부적으로 `tossctl ... --output json` 을 실행하고, `commandName`, `bin`, `args`, `data` 를 반환합니다.
세션 만료 관련:
- `account summary` 등은 만료 시 에러를 던집니다.
- 일부 커맨드(`portfolio positions`, `watchlist list`)는 upstream에서 빈 배열(`[]`)을 반환할 수 있어, 이 패키지는 기본적으로 `auth doctor`를 추가 확인해 만료를 `TossSessionExpiredError`로 승격합니다.
- 이 승격은 `auth doctor`가 파싱 가능한 JSON을 반환하고 `session.valid === false`로 명시 확인될 때만 발생합니다. `auth doctor` 실패, 파싱 불가 출력, 또는 `session.valid !== false`는 세션 만료 판정으로 취급하지 않습니다.
- 필요하면 `verifySessionOnEmpty: false`로 기존 빈 배열 동작을 유지할 수 있습니다.
대응되는 대표 CLI 는 `tossctl account summary --output json`, `tossctl quote get TSLA --output json`, `tossctl watchlist list --output json` 입니다.
## Usage

View file

@ -7,6 +7,15 @@ const {
} = require("./parse");
const execFile = util.promisify(childProcess.execFile);
const SESSION_EXPIRED_PATTERN = /stored session is no longer valid/iu;
class TossSessionExpiredError extends Error {
constructor(message, details = {}) {
super(message, { cause: details.cause });
this.name = "TossSessionExpiredError";
this.details = details;
}
}
function buildReadOnlyCommand(commandName, options = {}) {
return {
@ -15,6 +24,68 @@ function buildReadOnlyCommand(commandName, options = {}) {
};
}
function shouldVerifySessionOnEmpty(commandName, options = {}) {
if (options.verifySessionOnEmpty === false) {
return false;
}
return commandName === "portfolioPositions" || commandName === "watchlistList";
}
function isConfirmedInvalidSession(doctor) {
return doctor?.data?.session?.valid === false;
}
function buildSessionExpiredError(commandName, result, doctor, emptyKind) {
return new TossSessionExpiredError(
`tossctl ${commandName} returned ${emptyKind} while session is invalid. Run \`tossctl auth login\`.`,
{
commandName,
stderr: String(result.stderr || "").trim(),
doctor: doctor.data
}
);
}
function enrichQuote403Message(commandName, detail) {
const text = String(detail || "");
const isQuote = commandName === "quoteGet" || commandName === "quoteBatch";
if (!isQuote) {
return text;
}
if (/search\/stocks/iu.test(text) && /403/.test(text)) {
return `${text} | Upstream hint: if this recurs, report to https://github.com/JungHoonGhae/tossinvest-cli/issues/15 with timestamp and symbol.`;
}
return text;
}
async function checkSession(options = {}) {
const result = await runReadOnlyCommand("authDoctor", {
...options,
verifySessionOnEmpty: false
});
return result;
}
async function getConfirmedInvalidSession(options = {}) {
try {
const doctor = await checkSession(options);
if (isConfirmedInvalidSession(doctor)) {
return doctor;
}
} catch {
// Treat doctor execution/parsing failures as inconclusive. Empty portfolio
// and watchlist responses should only become TossSessionExpiredError when
// auth doctor returns parsed data that explicitly confirms invalid session.
}
return null;
}
async function runReadOnlyCommand(commandName, options = {}) {
const command = buildReadOnlyCommand(commandName, options);
@ -26,14 +97,42 @@ async function runReadOnlyCommand(commandName, options = {}) {
maxBuffer: options.maxBuffer || 1024 * 1024
});
if (shouldVerifySessionOnEmpty(commandName, options) && !String(result.stdout || "").trim()) {
const doctor = await getConfirmedInvalidSession(options);
if (doctor) {
throw buildSessionExpiredError(commandName, result, doctor, "empty output");
}
}
const parsed = parseJsonOutput(result.stdout, commandName);
if (shouldVerifySessionOnEmpty(commandName, options) && Array.isArray(parsed.data) && parsed.data.length === 0) {
const doctor = await getConfirmedInvalidSession(options);
if (doctor) {
throw buildSessionExpiredError(commandName, result, doctor, "empty array");
}
}
return {
...command,
...parseJsonOutput(result.stdout, commandName),
...parsed,
stderr: result.stderr
};
} catch (error) {
if (error instanceof TossSessionExpiredError) {
throw error;
}
const stderr = String(error.stderr || "").trim();
const detail = stderr || error.message;
const detail = enrichQuote403Message(commandName, stderr || error.message);
if (SESSION_EXPIRED_PATTERN.test(detail)) {
throw new TossSessionExpiredError(`tossctl ${commandName} failed: ${detail}`, {
commandName,
stderr,
cause: error
});
}
throw new Error(`tossctl ${commandName} failed: ${detail}`, {
cause: error
@ -85,6 +184,7 @@ function getQuoteBatch(symbols, options = {}) {
module.exports = {
buildReadOnlyCommand,
checkSession,
getAccountSummary,
getPortfolioAllocation,
getPortfolioPositions,
@ -94,5 +194,6 @@ module.exports = {
listCompletedOrders,
listOrders,
listWatchlist,
runReadOnlyCommand
runReadOnlyCommand,
TossSessionExpiredError
};

View file

@ -34,6 +34,9 @@ const READ_ONLY_COMMANDS = Object.freeze({
buildExtraArgs(options = {}) {
return normalizeSymbols(options.symbols);
}
},
authDoctor: {
segments: ["auth", "doctor"]
}
});

View file

@ -6,8 +6,12 @@ const path = require("node:path");
const {
buildReadOnlyCommand,
checkSession,
getAccountSummary,
getQuote
getPortfolioPositions,
getQuote,
listWatchlist,
TossSessionExpiredError
} = require("../src/index");
const {
assertReadOnlyCommandName,
@ -70,16 +74,16 @@ test("public helpers execute a mock tossctl binary and parse its JSON output", a
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
printf '%s\n' "$@" > "${logFile}"
printf '%s\\n' "$@" > "${logFile}"
if [ "$7" = "account" ] && [ "$8" = "summary" ]; then
printf '{"accountNo":"123-45","totalAssetAmount":1500000}\n'
printf '{"accountNo":"123-45","totalAssetAmount":1500000}\\n'
exit 0
fi
if [ "$3" = "quote" ] && [ "$4" = "get" ]; then
printf '{"symbol":"%s","price":123.45}\n' "$5"
printf '{"symbol":"%s","price":123.45}\\n' "$5"
exit 0
fi
printf '{"args":"%s"}\n' "$*"
printf '{"args":"%s"}\\n' "$*"
`;
const binPath = path.join(binDir, "tossctl");
@ -101,3 +105,270 @@ printf '{"args":"%s"}\n' "$*"
assert.equal(quote.data.symbol, "005930");
assert.match(fs.readFileSync(logFile, "utf8"), /quote|get/);
});
test("account summary invalid session throws TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-expire-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "account" ] && [ "$4" = "summary" ]; then
echo "Error: stored session is no longer valid; run \\\`tossctl auth login\\\`" 1>&2
exit 1
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
getAccountSummary({ env }),
(error) => error instanceof TossSessionExpiredError && /stored session is no longer valid/.test(error.message)
);
});
test("portfolio empty array with invalid auth doctor is promoted to TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-empty-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":false,"validation_error":"401"}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(getPortfolioPositions({ env }), TossSessionExpiredError);
const passthrough = await getPortfolioPositions({ env, verifySessionOnEmpty: false });
assert.deepEqual(passthrough.data, []);
});
test("portfolio empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await getPortfolioPositions({ env });
assert.deepEqual(result.data, []);
});
test("portfolio blank stdout with invalid auth doctor is promoted to TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-blank-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":false,"validation_error":"401"}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
getPortfolioPositions({ env }),
(error) =>
error instanceof TossSessionExpiredError &&
/returned empty output while session is invalid/.test(error.message)
);
await assert.rejects(
getPortfolioPositions({ env, verifySessionOnEmpty: false }),
/returned empty output/
);
});
test("portfolio blank stdout keeps auth doctor failures inconclusive", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-doctor-fail-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "portfolio" ] && [ "$4" = "positions" ]; then
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
echo "validation_error: transport failure" 1>&2
exit 1
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
getPortfolioPositions({ env }),
(error) =>
!(error instanceof TossSessionExpiredError) &&
/returned empty output/.test(error.message)
);
});
test("watchlist empty array with invalid auth doctor is promoted to TossSessionExpiredError", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-watchlist-empty-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "watchlist" ] && [ "$4" = "list" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":false,"validation_error":"401"}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(listWatchlist({ env }), TossSessionExpiredError);
const passthrough = await listWatchlist({ env, verifySessionOnEmpty: false });
assert.deepEqual(passthrough.data, []);
});
test("watchlist empty array is preserved when auth doctor does not confirm invalid session", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-watchlist-empty-valid-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "watchlist" ] && [ "$4" = "list" ]; then
printf '[]\\n'
exit 0
fi
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
printf '{"session":{"valid":true}}\\n'
exit 0
fi
printf '{"ok":true}\\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
const result = await listWatchlist({ env });
assert.deepEqual(result.data, []);
});
test("quote 403 includes upstream hint", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-403-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
echo "status 403 at search/stocks" 1>&2
exit 1
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
getQuote("ALM", { env }),
(error) =>
!(error instanceof TossSessionExpiredError) &&
/issues\/15/.test(error.message)
);
});
test("checkSession treats auth doctor validation_error failures as inconclusive command errors", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-check-session-fail-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
if [ "$3" = "auth" ] && [ "$4" = "doctor" ]; then
echo "validation_error: transport failure" 1>&2
exit 1
fi
printf '{"ok":true}\n'
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
checkSession({ env }),
(error) =>
!(error instanceof TossSessionExpiredError) &&
/validation_error: transport failure/.test(error.message)
);
});
test("quote search stocks 403 with validation_error remains a non-session upstream error", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "toss-securities-403-validation-error-"));
const binDir = path.join(tempDir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const script = `#!/bin/sh
echo "validation_error: status 403 at search/stocks" 1>&2
exit 1
`;
const binPath = path.join(binDir, "tossctl");
fs.writeFileSync(binPath, script, { mode: 0o755 });
const env = { ...process.env, PATH: `${binDir}:${process.env.PATH || ""}` };
await assert.rejects(
getQuote("ALM", { env }),
(error) =>
!(error instanceof TossSessionExpiredError) &&
/validation_error: status 403 at search\/stocks/.test(error.message) &&
/issues\/15/.test(error.message)
);
});