idx-cli/crates/idx-core/src/api/stockbit/loopback.rs
0xrsydn e7f1fa2c90 fix(stockbit): keep a working session through a failed login check
- login restored the previous session on any check error, even after the
  check had rotated the pasted session: a timeout then left no working
  session. It now keeps a rotated session unless Stockbit refused it.
- The browser sign-in marked a cookie refused on any error, so a 429 or a
  timeout held a signed-in user until the wait ran out. Passing errors
  are retried after a pause; only a refusal is final.
- The local page checked concurrent pastes on their own threads, so a
  later one could overwrite the session the first had just rotated.
  Pastes are now checked one at a time, and none after one is accepted.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
2026-11-04 17:31:20 +07:00

723 lines
29 KiB
Rust

//! The local page `idx stockbit login` opens: a loopback server on
//! 127.0.0.1 that takes the pasted session, checks it, and reports back.
//!
//! Stockbit has no OAuth redirect that could hand idx a session, and the
//! browser will not give one site another site's cookie, so the page takes
//! the value two ways, both one click:
//! - **Read my clipboard**: the user copies `credentialStorage` in DevTools
//! once and clicks the button (`navigator.clipboard`, allowed on localhost).
//! - **Bookmarklet**: a link rendered with this run's port and secret that
//! reads `document.cookie` on stockbit.com and opens the page with the value
//! in the URL *fragment*, which browsers never send to the server.
//!
//! Safeguards: bound to 127.0.0.1, a random port, a per-run secret path (no
//! secret, no page), a short life, `no-store`, no referrer, and a strict CSP.
//! The session value is held in memory only on the way to `stockbit::login`;
//! it is never written to a log or the response.
use std::io::{Read, Write};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::time::{Duration, Instant};
use crate::error::IdxError;
/// Largest paste accepted (the cookie is a few KB).
const MAX_BODY: usize = 16 * 1024;
/// Requests time out instead of pinning a thread; short, because a browser
/// preconnect sends nothing at all.
const IO_TIMEOUT: Duration = Duration::from_secs(3);
/// Connections answered at once; beyond this, new ones are dropped.
const MAX_CONNECTIONS: usize = 16;
/// A running login page.
pub struct LoginPage {
listener: TcpListener,
port: u16,
secret: String,
}
impl LoginPage {
/// Binds 127.0.0.1 on `port` (0 picks a free one).
pub fn bind(port: u16) -> Result<Self, IdxError> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, port)).map_err(|e| {
IdxError::Io(format!(
"cannot open the Stockbit login page on 127.0.0.1:{port}: {e}"
))
})?;
let port = listener
.local_addr()
.map_err(|e| IdxError::Io(e.to_string()))?
.port();
listener
.set_nonblocking(true)
.map_err(|e| IdxError::Io(e.to_string()))?;
Ok(Self {
listener,
port,
secret: random_secret(),
})
}
pub fn url(&self) -> String {
format!("http://127.0.0.1:{}/login/{}", self.port, self.secret)
}
/// The port the page listens on.
pub fn port(&self) -> u16 {
self.port
}
/// Serves until `verify` accepts a paste or `timeout` passes. The error
/// `verify` returns for a refused paste is shown on the page.
///
/// Each connection gets its own thread: browsers open idle preconnects,
/// and answering them one at a time used to hold the page back for the
/// read timeout on every one. Pastes are checked one at a time, and none
/// after one is accepted: two at once (a double click, the bookmarklet
/// and the clipboard button) would each store a session, and the later
/// could overwrite the one Stockbit had just rotated.
pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
where
F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static,
{
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let accepted = std::sync::Mutex::new(false);
let verify = std::sync::Arc::new(move |paste: &str| {
let mut accepted = accepted.lock().unwrap_or_else(|e| e.into_inner());
if !*accepted {
verify(paste)?;
*accepted = true;
}
Ok(())
});
let page = std::sync::Arc::new(self.page());
let path = std::sync::Arc::new(format!("/login/{}", self.secret));
let busy = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let deadline = Instant::now() + timeout;
loop {
if done_rx.try_recv().is_ok() {
return Ok(());
}
if Instant::now() >= deadline {
break;
}
match self.listener.accept() {
Ok((stream, _)) => {
// Browsers open a handful; anything beyond this is junk.
if busy.load(std::sync::atomic::Ordering::Relaxed) >= MAX_CONNECTIONS {
continue;
}
busy.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let (verify, page, path) = (verify.clone(), page.clone(), path.clone());
let busy = busy.clone();
let done = done_tx.clone();
std::thread::spawn(move || {
if serve_once(stream, &path, &page, verify.as_ref()) {
let _ = done.send(());
}
busy.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
});
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(20));
}
Err(e) => return Err(IdxError::Io(format!("stockbit login page: {e}"))),
}
}
Err(IdxError::Io(format!(
"the Stockbit login page timed out after {}s; its URL was {} (re-run it, or pin the \
port with --port)",
timeout.as_secs(),
self.url()
)))
}
/// The page, with this run's port and secret baked into the bookmarklet.
fn page(&self) -> String {
let target = self.url();
// Reads stockbit.com's session cookie and opens the page with it in
// the fragment (never sent to a server, never in a referrer).
let bookmarklet = format!(
"javascript:(()=>{{const m=document.cookie.match(/(?:^|;\\s*)credentialStorage=([^;]*)/);\
if(!m){{alert('No Stockbit session in this browser: log in to stockbit.com first.');return}}\
window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
);
PAGE.replace("__TARGET__", &target)
.replace("__BOOKMARKLET__", &html_escape(&bookmarklet))
}
}
/// The forward to run where the browser is, for the page on `port`:
/// `ssh -L <port>:127.0.0.1:<port> <user>@<host>`.
///
/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip
/// server_port`) when idx runs inside an SSH session, else from `USER` and
/// `HOSTNAME`, else it is left for the reader to fill in.
pub fn ssh_forward(port: u16) -> String {
let target = ssh_target().unwrap_or_else(|| "<this host>".to_string());
format!("ssh -L {port}:127.0.0.1:{port} {target}")
}
fn ssh_target() -> Option<String> {
if let Ok(connection) = std::env::var("SSH_CONNECTION") {
let fields: Vec<&str> = connection.split_whitespace().collect();
if let Some(server) = fields.get(2) {
return Some(match std::env::var("USER") {
Ok(user) => format!("{user}@{server}"),
Err(_) => (*server).to_string(),
});
}
}
let user = std::env::var("USER").ok()?;
let host = std::env::var("HOSTNAME").ok()?;
Some(format!("{user}@{host}"))
}
/// Answers one request; true when a paste was accepted.
fn serve_once(
mut stream: TcpStream,
page_path: &str,
page: &str,
verify: &dyn Fn(&str) -> Result<(), IdxError>,
) -> bool {
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
let Some(request) = read_request(&mut stream) else {
// An idle preconnect or a malformed request: drop it quietly.
return false;
};
match (request.method.as_str(), request.path.as_str()) {
("GET", p) if p == page_path => {
respond(
&mut stream,
200,
"text/html; charset=utf-8",
page.as_bytes(),
);
}
("POST", p) if p == page_path => match verify(&request.body) {
Ok(()) => {
respond(
&mut stream,
200,
"application/json",
br#"{"ok":true,"message":"Connected. You can close this tab."}"#,
);
return true;
}
Err(err) => {
let body = serde_json::json!({ "ok": false, "message": err.to_string() });
respond(
&mut stream,
200,
"application/json",
body.to_string().as_bytes(),
);
}
},
("GET", "/favicon.ico") => respond(&mut stream, 204, "text/plain", b""),
_ => respond(&mut stream, 404, "text/plain", b"not found"),
}
false
}
fn random_secret() -> String {
(0..4)
.map(|_| format!("{:08x}", fastrand::u32(..)))
.collect::<Vec<_>>()
.join("")
}
fn html_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
struct Request {
method: String,
path: String,
body: String,
}
/// A minimal HTTP/1.1 request: request line, headers, and a bounded body.
fn read_request(stream: &mut TcpStream) -> Option<Request> {
let mut raw = Vec::new();
let mut chunk = [0u8; 2048];
let header_end = loop {
let n = stream.read(&mut chunk).ok()?;
if n == 0 {
return None;
}
raw.extend_from_slice(&chunk[..n]);
if let Some(at) = find(&raw, b"\r\n\r\n") {
break at + 4;
}
if raw.len() > MAX_BODY {
return None;
}
};
let head = String::from_utf8_lossy(&raw[..header_end]).into_owned();
let mut lines = head.lines();
let mut request_line = lines.next()?.split_whitespace();
let method = request_line.next()?.to_string();
let path = request_line.next()?.split('?').next()?.to_string();
let length = lines
.filter_map(|line| line.split_once(':'))
.find(|(name, _)| name.eq_ignore_ascii_case("content-length"))
.and_then(|(_, value)| value.trim().parse::<usize>().ok())
.unwrap_or(0)
.min(MAX_BODY);
let mut body = raw[header_end..].to_vec();
while body.len() < length {
let n = stream.read(&mut chunk).ok()?;
if n == 0 {
break;
}
body.extend_from_slice(&chunk[..n]);
}
body.truncate(length);
Some(Request {
method,
path,
body: String::from_utf8_lossy(&body).into_owned(),
})
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack
.windows(needle.len())
.position(|window| window == needle)
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8]) {
let reason = match status {
200 => "OK",
204 => "No Content",
400 => "Bad Request",
_ => "Not Found",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\n\
X-Content-Type-Options: nosniff\r\n\
Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; \
script-src 'unsafe-inline'; connect-src 'self'\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
let _ = stream.flush();
}
const PAGE: &str = r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Connect Stockbit to idx</title>
<style>
:root{
color-scheme:light dark;
--bg:#f4f5f8;--glow:rgba(47,92,240,.10);--panel:#fff;--panel-2:#f7f8fb;
--ink:#12141a;--muted:#6a7180;--line:#e5e8ef;--line-strong:#d2d7e2;
--accent:#2f5cf0;--accent-ink:#fff;
--ok:#0e7a48;--ok-bg:#eaf7f0;--ok-line:#bfe6d1;
--err:#b42318;--err-bg:#fdf2f1;--err-line:#f0cecb;
--shadow:0 1px 2px rgba(16,24,40,.05),0 18px 40px -20px rgba(16,24,40,.25)
}
@media (prefers-color-scheme:dark){:root{
--bg:#0b0c0f;--glow:rgba(122,162,255,.09);--panel:#14161b;--panel-2:#191c22;
--ink:#edeef2;--muted:#99a0b0;--line:#252932;--line-strong:#333a46;
--accent:#7aa2ff;--accent-ink:#0b0c0f;
--ok:#5fd39a;--ok-bg:#0f1f19;--ok-line:#1e4135;
--err:#ff8f86;--err-bg:#231413;--err-line:#4a2523;
--shadow:0 1px 2px rgba(0,0,0,.5),0 18px 44px -20px rgba(0,0,0,.9)
}}
*{box-sizing:border-box}
body{margin:0;padding:clamp(20px,5vw,56px) 20px;background:var(--bg);
background-image:radial-gradient(900px 420px at 50% -180px,var(--glow),transparent 70%);
background-repeat:no-repeat;color:var(--ink);
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
-webkit-font-smoothing:antialiased}
.card{max-width:640px;margin:0 auto;background:var(--panel);border:1px solid var(--line);
border-radius:20px;padding:clamp(24px,4.4vw,40px);box-shadow:var(--shadow)}
.brand{display:flex;align-items:center;gap:9px;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:20px}
.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--accent);color:var(--accent-ink)}
.mark svg{width:16px;height:16px}
.sep{opacity:.45}
h1{font-size:clamp(22px,3.2vw,26px);line-height:1.25;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance}
.lede{color:var(--muted);margin:0 0 26px}
.steps{list-style:none;margin:0;padding:0;display:grid;gap:12px}
.step{display:flex;gap:14px;border:1px solid var(--line);background:var(--panel-2);border-radius:14px;padding:16px 18px}
.num{flex:0 0 auto;display:grid;place-items:center;width:24px;height:24px;margin-top:2px;border-radius:50%;
border:1px solid var(--line-strong);background:var(--panel);color:var(--muted);font-size:12px;font-weight:700}
.step h2{font-size:15px;letter-spacing:-.01em;margin:0 0 4px}
.step p{margin:0 0 13px;color:var(--muted);font-size:14px}
.step p:last-child{margin-bottom:0}
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid transparent;border-radius:10px;
padding:10px 16px;font:inherit;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;
text-decoration:none;background:var(--accent);color:var(--accent-ink);
box-shadow:0 1px 2px rgba(16,24,40,.10);transition:filter .12s ease,transform .12s ease,background-color .12s ease}
.btn:hover{filter:brightness(1.07)}
.btn:active{transform:translateY(1px)}
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line-strong);box-shadow:none}
.btn.ghost:hover{background:var(--panel-2);filter:none}
.btn[disabled]{opacity:.55;cursor:progress;transform:none}
.hint{margin:13px 0 0;color:var(--muted);font-size:13px}
.manual{margin-top:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;
cursor:pointer;font-size:14px;font-weight:600;list-style:none}
summary::-webkit-details-marker{display:none}
summary svg{width:16px;height:16px;flex:0 0 auto;color:var(--muted);transition:transform .16s ease}
.manual[open] summary svg{transform:rotate(180deg)}
.manual-body{padding:0 18px 18px;border-top:1px solid var(--line)}
.mini{margin:15px 0;padding-left:19px;color:var(--muted);font-size:13.5px;line-height:1.75}
.mini li::marker{color:var(--line-strong)}
textarea{width:100%;min-height:84px;border:1px solid var(--line-strong);border-radius:10px;
background:var(--panel);color:var(--ink);padding:11px 12px;resize:vertical;
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
textarea::placeholder{color:var(--muted);opacity:.75}
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--panel-2);
border:1px solid var(--line);border-radius:6px;padding:1px 5px;white-space:nowrap}
kbd{font:12px ui-monospace,Menlo,monospace;background:var(--panel-2);border:1px solid var(--line-strong);
border-bottom-width:2px;border-radius:5px;padding:0 5px}
em{font-style:normal;color:var(--ink)}
.status{display:flex;align-items:center;gap:10px;margin:18px 0 0;padding:12px 14px;
border:1px solid transparent;border-radius:11px;font-size:14px;font-weight:600}
.status[hidden]{display:none}
.status.pending{color:var(--muted);background:var(--panel-2);border-color:var(--line)}
.status.pending::before{content:"";flex:0 0 auto;width:14px;height:14px;border-radius:50%;
border:2px solid var(--line-strong);border-top-color:var(--accent);animation:spin .7s linear infinite}
.status.err{color:var(--err);background:var(--err-bg);border-color:var(--err-line)}
.status.err::before{content:"!";flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;
border-radius:50%;background:var(--err);color:var(--err-bg);font-size:11px;font-weight:800}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
.status.pending::before{animation-duration:2.4s}
.btn{transition:none}
}
.done{padding:6px 0 10px;text-align:center}
.done[hidden]{display:none}
.done .check{display:grid;place-items:center;width:52px;height:52px;margin:0 auto 16px;
border-radius:50%;background:var(--ok-bg);border:1px solid var(--ok-line);color:var(--ok)}
.done .check svg{width:26px;height:26px}
.done h2{font-size:18px;letter-spacing:-.01em;margin:0 0 6px}
.done p{margin:0;color:var(--muted);font-size:14px}
.foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);
color:var(--muted);font-size:13px;line-height:1.65}
.foot p{margin:0 0 8px}
.foot p:last-child{margin-bottom:0}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
</style></head>
<body><main class="card">
<div class="brand">
<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.7 14.3 14.3 9.7"/><path d="M7.4 12.6l-1.3 1.3a3.6 3.6 0 0 0 5 5l1.3-1.3"/><path d="M16.6 11.4l1.3-1.3a3.6 3.6 0 0 0-5-5L11.6 6.4"/></svg></span>
<span>idx</span><span class="sep" aria-hidden="true">/</span><span>Stockbit</span>
</div>
<header class="head">
<h1>Connect your Stockbit account</h1>
<p class="lede">idx then reads live quotes and the order book with your own session. The session stays on this machine.</p>
</header>
<section id="app">
<ol class="steps">
<li class="step">
<span class="num" aria-hidden="true">1</span>
<div>
<h2>Sign in to Stockbit</h2>
<p>Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.</p>
<a class="btn ghost" href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">Open stockbit.com</a>
</div>
</li>
<li class="step">
<span class="num" aria-hidden="true">2</span>
<div>
<h2>Send the session to idx</h2>
<p>While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.</p>
<a class="btn" id="bookmarklet" href="__BOOKMARKLET__">Send my Stockbit session</a>
<p class="hint">If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.</p>
</div>
</li>
</ol>
<details class="manual" id="manual">
<summary><span>Or paste the cookie by hand</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="manual-body">
<ol class="mini">
<li>On stockbit.com, open DevTools with <kbd>F12</kbd>.</li>
<li>Go to <em>Application</em>, then <em>Cookies</em>, then <code>https://stockbit.com</code>.</li>
<li>Copy the value of <code>credentialStorage</code> and paste it below.</li>
</ol>
<textarea id="paste" placeholder="credentialStorage value" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
<div class="row">
<button class="btn" id="send">Connect</button>
<button class="btn ghost" id="clip">Paste from clipboard</button>
</div>
</div>
</details>
<p id="status" class="status" role="status" aria-live="polite" hidden></p>
</section>
<section id="done" class="done" hidden aria-live="polite">
<span class="check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6.5-10.5 11L4 12"/></svg></span>
<h2>Connected</h2>
<p id="done-msg">You can close this tab and go back to idx.</p>
</section>
<footer class="foot">
<p>idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.</p>
<p>This page stops listening after a few minutes.</p>
</footer>
</main>
<script>
const target = "__TARGET__";
const app = document.getElementById('app');
const done = document.getElementById('done');
const statusEl = document.getElementById('status');
const manual = document.getElementById('manual');
const paste = document.getElementById('paste');
const head = document.querySelector('.head');
const buttons = [document.getElementById('send'), document.getElementById('clip')];
let busy = false;
function setStatus(text, kind) {
statusEl.hidden = !text;
statusEl.className = 'status' + (kind ? ' ' + kind : '');
statusEl.textContent = text || '';
}
function setBusy(value) {
busy = value;
for (const button of buttons) { button.disabled = value; }
}
function askForPaste(message) {
if (message) { setStatus(message, 'err'); }
manual.open = true;
paste.focus();
}
async function send(value) {
const text = (value || '').trim();
if (busy) { return false; }
if (!text) { askForPaste('Paste the credentialStorage value first.'); return false; }
setBusy(true);
setStatus('Checking the session with Stockbit\u2026', 'pending');
try {
const response = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: text
});
const result = await response.json();
if (result.ok) {
setStatus('');
head.hidden = true;
app.hidden = true;
done.hidden = false;
return true;
}
setStatus(result.message, 'err');
} catch (error) {
setStatus('Could not reach idx: ' + error, 'err');
} finally {
setBusy(false);
}
return false;
}
document.getElementById('send').addEventListener('click', () => send(paste.value));
document.getElementById('clip').addEventListener('click', async () => {
try {
send(await navigator.clipboard.readText());
} catch (error) {
askForPaste('The browser blocked the clipboard. Paste the value into the box instead.');
}
});
paste.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send(paste.value);
}
});
if (location.hash.startsWith('#t=')) {
const value = decodeURIComponent(location.hash.slice(3));
history.replaceState(null, '', location.pathname);
send(value).then((ok) => {
if (!ok) { paste.value = value; manual.open = true; }
});
}
</script>
</body></html>
"#;
#[cfg(test)]
mod tests {
use std::io::{Read as _, Write as _};
use std::net::TcpStream;
use super::*;
fn request(url: &str, method: &str, body: &str) -> (u16, String) {
let rest = url.trim_start_matches("http://");
let (authority, path) = rest.split_once('/').expect("path");
let mut stream = TcpStream::connect(authority).expect("connect");
// A server that never answers must fail the test, not hang it.
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
write!(
stream,
"{method} /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
.unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let status = raw
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.expect("status");
let body = raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
(status, body)
}
/// Two pastes at once are checked one at a time, and the second is not
/// checked at all once the first is accepted.
#[test]
fn concurrent_pastes_are_checked_once() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let checks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = checks.clone();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), move |_| {
counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(300));
Ok(())
})
});
std::thread::sleep(Duration::from_millis(100));
let clients: Vec<_> = (0..2)
.map(|_| {
let url = url.clone();
std::thread::spawn(move || request(&url, "POST", "good"))
})
.collect();
for client in clients {
let (status, body) = client.join().unwrap();
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"), "{body}");
}
handle.join().unwrap().unwrap();
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[test]
fn serves_the_page_only_with_the_secret_and_accepts_a_good_paste() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let bad = url.replace(&page.secret, "not-the-secret");
let handle = std::thread::spawn(move || {
let verify = |paste: &str| match paste.trim() {
"good" => Ok(()),
_ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())),
};
page.wait(Duration::from_secs(10), verify)
});
std::thread::sleep(Duration::from_millis(100));
assert_eq!(request(&bad, "GET", "").0, 404);
let (status, html) = request(&url, "GET", "");
assert_eq!(status, 200);
assert!(html.contains("Connect Stockbit to idx"));
assert!(html.contains("javascript:"), "bookmarklet rendered");
// The bookmarklet carries this run's page and a fragment, not the
// secret in a query string.
assert!(html.contains(&format!("{url}#t=")), "bookmarklet target");
let (status, body) = request(&url, "POST", "nonsense");
assert_eq!(status, 200);
assert!(body.contains("\"ok\":false"), "{body}");
let (status, body) = request(&url, "POST", "good");
assert_eq!(status, 200);
assert!(body.contains("\"ok\":true"), "{body}");
handle.join().unwrap().expect("accepted");
}
/// The CSP allows no fetch of its own, so a stray asset would fail
/// silently; the page also has to keep the two entry points the docs name.
#[test]
fn the_page_is_self_contained_and_keeps_both_entry_points() {
let page = LoginPage::bind(0).unwrap().page();
for forbidden in ["<script src", "<link", "@import", "url(http"] {
assert!(!page.contains(forbidden), "page needs {forbidden}");
}
assert_eq!(
page.matches("https://").count(),
2,
"one link out, and the cookie path spelled out"
);
for url in page.split("https://").skip(1) {
assert!(
url.starts_with("stockbit.com"),
"page links somewhere else: {}",
&url[..url.len().min(40)]
);
}
assert!(page.contains("https://stockbit.com/symbol/BBCA"));
assert!(page.contains("Send my Stockbit session"));
assert!(page.contains("Paste from clipboard"));
}
#[test]
fn times_out_without_a_paste() {
let page = LoginPage::bind(0).unwrap();
let started = Instant::now();
let err = page
.wait(Duration::from_millis(150), |_| {
Err(IdxError::InvalidInput("no".to_string()))
})
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
}
/// Browsers open idle preconnects; one must not delay the page.
#[test]
fn an_idle_connection_does_not_delay_the_page() {
let page = LoginPage::bind(0).unwrap();
let url = page.url();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), |_| {
Err(IdxError::InvalidInput("not a Stockbit session".to_string()))
})
});
std::thread::sleep(Duration::from_millis(100));
// Two sockets that say nothing, as Chrome's preconnects do.
let rest = url.trim_start_matches("http://");
let (authority, _) = rest.split_once('/').unwrap();
let idle: Vec<TcpStream> = (0..2)
.map(|_| TcpStream::connect(authority).unwrap())
.collect();
std::thread::sleep(Duration::from_millis(100));
let started = Instant::now();
let (status, _) = request(&url, "GET", "");
assert_eq!(status, 200);
assert!(
started.elapsed() < Duration::from_secs(2),
"page waited {:?} behind idle connections",
started.elapsed()
);
drop(idle);
drop(handle);
}
}