Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/sources/tiingo/test/local-ws-test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Tiingo WS Failover — Manual Integration Tests

These scripts test end-to-end WebSocket failover behaviour by running two local
proxies between the EA and the real Tiingo upstream, then artificially triggering
abnormal closures via the proxy control API.

## What is tested

- All four transports subscribe and receive live data (IEX, crypto, crypto-lwba, forex)
- Abnormal WS closures (code 1006 / TCP terminate) increment the failover counter
- The 2:1 primary/secondary cycle is respected across 6 rounds of closures:
- counter=1 → cycle=1 → primary
- counter=2 → cycle=2 → **SECONDARY** (failover)
- counter=3 → cycle=0 → primary (failback)
- counter=4 → cycle=1 → primary
- counter=5 → cycle=2 → **SECONDARY** (failover again)
- counter=6 → cycle=0 → primary (failback again)
- IEX always stays on primary (its URL is hardcoded; it does not participate in failover)

## Prerequisites

1. Build the Tiingo EA dist:

```bash
yarn workspace @chainlink/tiingo-adapter build
```

2. Export your Tiingo API key:

```bash
export TIINGO_API_KEY=<your-key>
```

3. `curl`, `python3`, and `npm` must be on your PATH.
On first run the script installs the `ws` package into `/tmp/tiingo-proxy-modules`
(outside the repo) so the proxy can run without interfering with Yarn PnP.

## Running

```bash
export TIINGO_API_KEY=<your-key>
bash test/local-ws-test/test-failover.sh
```

Optional environment overrides:
| Variable | Default | Description |
|-----------------------|---------|------------------------------------|
| `EA_PORT` | 8181 | Port for the local EA HTTP server |
| `PRIMARY_PORT` | 9001 | Port for the primary WS proxy |
| `PRIMARY_CTRL` | 9002 | Control HTTP port for primary proxy |
| `SECONDARY_PORT` | 9003 | Port for the secondary WS proxy |
| `SECONDARY_CTRL` | 9004 | Control HTTP port for secondary proxy |
| `PRIMARY_ATTEMPTS` | 2 | Attempts on primary per cycle |
| `SECONDARY_ATTEMPTS` | 1 | Attempts on secondary per cycle |

## Proxy control API

While the proxy is running you can query and control it directly:

```bash
# List open connections
curl http://localhost:9002/status

# Close all connections abruptly (simulates code 1005 / no status received)
curl -X POST "http://localhost:9002/close?code=1005"

# Close only the IEX connection
curl -X POST "http://localhost:9002/close?code=1005&path=/iex"

# Normal close
curl -X POST "http://localhost:9002/close?code=1000"
```

## Log files

After a run, full logs are available at:

- `/tmp/tiingo-ea.log` — EA output
- `/tmp/proxy-primary.log` — primary proxy
- `/tmp/proxy-secondary.log` — secondary proxy
151 changes: 151 additions & 0 deletions packages/sources/tiingo/test/local-ws-test/proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* WebSocket proxy for Tiingo failover testing.
*
* Forwards WS connections from the EA to a real upstream, and exposes an HTTP
* control server to trigger artificial closes and inspect open connections.
*
* Usage:
* UPSTREAM_WS_URL=wss://api.tiingo.com PROXY_PORT=9001 CONTROL_PORT=9002 node proxy.js
*
* Control endpoints:
* GET http://localhost:$CONTROL_PORT/status – list open connections
* POST http://localhost:$CONTROL_PORT/close?code=1005 – close all connections
* POST http://localhost:$CONTROL_PORT/close?code=1005&path=/iex – close by path
*/

'use strict'

const WebSocket = require('ws')
const http = require('http')
const url = require('url')

const PROXY_PORT = parseInt(process.env.PROXY_PORT || '9001', 10)
const CONTROL_PORT = parseInt(process.env.CONTROL_PORT || String(PROXY_PORT + 1), 10)
const UPSTREAM_WS_URL = process.env.UPSTREAM_WS_URL || 'wss://api.tiingo.com'

const activePairs = [] // { id, clientWs, upstreamWs, path }
let connectionCounter = 0

// ── WebSocket proxy server ────────────────────────────────────────────────────
const wss = new WebSocket.Server({ port: PROXY_PORT })
console.log(`[proxy] Listening on ws://localhost:${PROXY_PORT}`)
console.log(`[proxy] Forwarding to ${UPSTREAM_WS_URL}`)

wss.on('connection', (clientWs, req) => {
const id = ++connectionCounter
const path = req.url || ''
const upstreamUrl = `${UPSTREAM_WS_URL}${path}`
console.log(`[proxy][${id}] EA connected, opening upstream: ${upstreamUrl}`)

Check warning

Code scanning / CodeQL

Log injection Medium test

Log entry depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

In general, to fix log injection when logging user input, sanitize the data before logging by removing or neutralizing characters that can alter the log structure (such as \r, \n, and other control characters). For plain text logs, stripping CR/LF is usually sufficient; for HTML logs, HTML-encoding is needed. It is also good practice to clearly delimit or quote user-provided data in the log message.

For this specific code, the best minimal fix is to ensure that upstreamUrl is sanitized before it is interpolated into the log message on line 39. We can do this by creating a sanitized version of upstreamUrl specifically for logging, e.g. safeUpstreamUrl, which strips carriage return and newline characters (and optionally other control characters) using String.prototype.replace with a regular expression. We should only change the log line (and possibly introduce a one-line local variable) without altering the behavior of the proxy logic: the actual upstreamUrl used to establish the WebSocket connection must remain unchanged. No additional imports are required; we can rely on built-in string methods.

Concretely, inside the wss.on('connection', ...) handler in packages/sources/tiingo/test/local-ws-test/proxy.js, after computing const upstreamUrl = \${UPSTREAM_WS_URL}${path}`, we introduce const safeUpstreamUrl = upstreamUrl.replace(/[\r\n]/g, '')and then change theconsole.logcall to logsafeUpstreamUrlinstead ofupstreamUrl`. This keeps functionality identical (the upstream connection still uses the original URL) while ensuring logs cannot be structurally modified via CR/LF injection.

Suggested changeset 1
packages/sources/tiingo/test/local-ws-test/proxy.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/sources/tiingo/test/local-ws-test/proxy.js b/packages/sources/tiingo/test/local-ws-test/proxy.js
--- a/packages/sources/tiingo/test/local-ws-test/proxy.js
+++ b/packages/sources/tiingo/test/local-ws-test/proxy.js
@@ -36,7 +36,8 @@
   const id = ++connectionCounter
   const path = req.url || ''
   const upstreamUrl = `${UPSTREAM_WS_URL}${path}`
-  console.log(`[proxy][${id}] EA connected, opening upstream: ${upstreamUrl}`)
+  const safeUpstreamUrl = upstreamUrl.replace(/[\r\n]/g, '')
+  console.log(`[proxy][${id}] EA connected, opening upstream: ${safeUpstreamUrl}`)
 
   const upstreamWs = new WebSocket(upstreamUrl)
   const pair = { id, clientWs, upstreamWs, path }
EOF
@@ -36,7 +36,8 @@
const id = ++connectionCounter
const path = req.url || ''
const upstreamUrl = `${UPSTREAM_WS_URL}${path}`
console.log(`[proxy][${id}] EA connected, opening upstream: ${upstreamUrl}`)
const safeUpstreamUrl = upstreamUrl.replace(/[\r\n]/g, '')
console.log(`[proxy][${id}] EA connected, opening upstream: ${safeUpstreamUrl}`)

const upstreamWs = new WebSocket(upstreamUrl)
const pair = { id, clientWs, upstreamWs, path }
Copilot is powered by AI and may make mistakes. Always verify output.

const upstreamWs = new WebSocket(upstreamUrl)
const pair = { id, clientWs, upstreamWs, path }
activePairs.push(pair)

// Buffer messages from EA that arrive before upstream is ready
const pendingMessages = []

upstreamWs.on('open', () => {
console.log(
`[proxy][${id}] Upstream connected — flushing ${pendingMessages.length} buffered message(s)`,
)
for (const msg of pendingMessages) {
upstreamWs.send(msg.toString('utf8')) // send as text frame, not binary
}
pendingMessages.length = 0
})

upstreamWs.on('message', (data) => {
if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data)
})

clientWs.on('message', (data) => {
if (upstreamWs.readyState === WebSocket.OPEN) {
upstreamWs.send(data)
} else {
pendingMessages.push(data)
}
})

upstreamWs.on('close', (code, reason) => {
console.log(`[proxy][${id}] Upstream closed: code=${code} reason=${reason?.toString() || ''}`)
if (clientWs.readyState === WebSocket.OPEN) {
// 1005/1006 cannot be sent in a close frame — terminate the TCP connection instead
if (code === 1005 || code === 1006) {
clientWs.terminate()
} else {
clientWs.close(code, reason)
}
}
removePair(id)
})

upstreamWs.on('error', (err) => {
console.error(`[proxy][${id}] Upstream error: ${err.message}`)
})

clientWs.on('close', (code, reason) => {
console.log(`[proxy][${id}] EA closed: code=${code} reason=${reason?.toString() || ''}`)
if (upstreamWs.readyState === WebSocket.OPEN) upstreamWs.close()
removePair(id)
})

clientWs.on('error', (err) => {
console.error(`[proxy][${id}] EA error: ${err.message}`)
})
})

function removePair(id) {
const idx = activePairs.findIndex((p) => p.id === id)
if (idx !== -1) activePairs.splice(idx, 1)
}

// ── Control HTTP server ───────────────────────────────────────────────────────
const controlServer = http.createServer((req, res) => {
const parsed = url.parse(req.url, true)

if (req.method === 'GET' && parsed.pathname === '/status') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
openConnections: activePairs.length,
connections: activePairs.map((p) => ({ id: p.id, path: p.path })),
}),
)
return
}

if (req.method === 'POST' && parsed.pathname === '/close') {
const code = parseInt(parsed.query.code || '1005', 10)
const reason = parsed.query.reason || ''
const pathFilter = parsed.query.path || null

const targets = [...activePairs].filter((p) => !pathFilter || p.path === pathFilter)

console.log(
`[control] Closing ${targets.length} connection(s) with code=${code}` +
(pathFilter ? ` path=${pathFilter}` : ''),
Comment on lines +126 to +127

Check warning

Code scanning / CodeQL

Log injection Medium test

Log entry depends on a
user-provided value
.

Copilot Autofix

AI about 2 months ago

To fix the problem, any user-controlled data included in log messages should be sanitized before logging. For plain text logs, the key step is to remove or neutralize newline characters (\r, \n) so an attacker cannot inject additional log lines. It also helps to clearly delimit user-supplied values.

In this file, the only problematic user-controlled value in the highlighted log call is pathFilter. The best minimal fix is to derive a sanitized version of pathFilter (e.g., safePathFilter) by stripping carriage returns and newlines using String.prototype.replace, then log only that sanitized value. This keeps the existing functionality (filtering by path and closing connections) unchanged while ensuring logs cannot be broken by crafted input.

Concretely:

  • After determining pathFilter on line 121, compute safePathFilter as either null (if no filter) or String(pathFilter).replace(/\r|\n/g, '').
  • Use safePathFilter instead of pathFilter in the console.log message on line 125–128.
  • Optionally, also use safePathFilter in the JSON response on line 141 to keep logged/returned values consistent and sanitized, though CodeQL only cares about the log sink.

No new external libraries are necessary; String.prototype.replace with a simple regex is sufficient.

Suggested changeset 1
packages/sources/tiingo/test/local-ws-test/proxy.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/sources/tiingo/test/local-ws-test/proxy.js b/packages/sources/tiingo/test/local-ws-test/proxy.js
--- a/packages/sources/tiingo/test/local-ws-test/proxy.js
+++ b/packages/sources/tiingo/test/local-ws-test/proxy.js
@@ -119,12 +119,16 @@
     const code = parseInt(parsed.query.code || '1005', 10)
     const reason = parsed.query.reason || ''
     const pathFilter = parsed.query.path || null
+    const safePathFilter =
+      pathFilter === null || pathFilter === undefined
+        ? null
+        : String(pathFilter).replace(/\r|\n/g, '')
 
     const targets = [...activePairs].filter((p) => !pathFilter || p.path === pathFilter)
 
     console.log(
       `[control] Closing ${targets.length} connection(s) with code=${code}` +
-        (pathFilter ? ` path=${pathFilter}` : ''),
+        (safePathFilter ? ` path=${safePathFilter}` : ''),
     )
 
     for (const pair of targets) {
@@ -138,7 +137,7 @@
     }
 
     res.writeHead(200, { 'Content-Type': 'application/json' })
-    res.end(JSON.stringify({ closed: targets.length, code, path: pathFilter }))
+    res.end(JSON.stringify({ closed: targets.length, code, path: safePathFilter }))
     return
   }
 
EOF
@@ -119,12 +119,16 @@
const code = parseInt(parsed.query.code || '1005', 10)
const reason = parsed.query.reason || ''
const pathFilter = parsed.query.path || null
const safePathFilter =
pathFilter === null || pathFilter === undefined
? null
: String(pathFilter).replace(/\r|\n/g, '')

const targets = [...activePairs].filter((p) => !pathFilter || p.path === pathFilter)

console.log(
`[control] Closing ${targets.length} connection(s) with code=${code}` +
(pathFilter ? ` path=${pathFilter}` : ''),
(safePathFilter ? ` path=${safePathFilter}` : ''),
)

for (const pair of targets) {
@@ -138,7 +137,7 @@
}

res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ closed: targets.length, code, path: pathFilter }))
res.end(JSON.stringify({ closed: targets.length, code, path: safePathFilter }))
return
}

Copilot is powered by AI and may make mistakes. Always verify output.
)

for (const pair of targets) {
if (pair.clientWs.readyState === WebSocket.OPEN) {
if (code === 1005 || code === 1006) {
pair.clientWs.terminate()
} else {
pair.clientWs.close(code, reason)
}
}
}

res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ closed: targets.length, code, path: pathFilter }))
return
}

res.writeHead(404)
res.end('Not found')
})

controlServer.listen(CONTROL_PORT, () => {
console.log(`[control] HTTP on http://localhost:${CONTROL_PORT}`)
})
Loading
Loading