Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
node_modules/
package.json
package-lock.json

# A host app writes its credentials here (shared/handoff.js). Never commit one.
extension/handoff.json
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,68 @@ Chrome 123 or newer. To update: unpack a newer zip over the same folder, or
There is no build step: `extension/` runs exactly as it is checked in. No npm
install, no bundler, no compiler.

## Signed in by another app

An app that launches Chrome with this extension loaded — Testeiya, for one — can
sign the panel in itself, so the tester never pastes a token. It writes
`handoff.json` into the extension folder and opens the panel:

```json
{
"app": "Testeiya",
"baseUrl": "https://app.testomat.io",
"projectId": "my-project",
"jwt": "eyJ…",
"projectToken": "tstmt_…",
"runUrl": "https://app.testomat.io/projects/my-project/runs/abcd1234",
"at": 1756160000000
}
```

Two credentials, because the panel talks to two APIs: `projectToken` is what
`/api/v2` takes, `jwt` is a web session for the routes v2 lacks. `runUrl` is
optional; with one, the panel opens that run. `at` is milliseconds since the
epoch and has to grow on every push.

A file rather than a command line: `--load-extension` argv is readable by every
process on the machine, and these are credentials. Rules the panel follows:

- Only `projectToken` and the project are stored; the `jwt` is held in memory and
re-read from the file, exactly like the one `POST /api/login` returns.
- The file has to stay for as long as the connection should work. Whoever wrote
it deletes it — closing the browser it launched is the usual moment.
- **Disconnect** marks that `at` as answered instead of deleting a file it does
not own. Push a newer `at` to offer the connection again.
- A run is opened once per `at`, so reloading the panel keeps the tester's place.

### Beside a token the tester pasted

An offer is an overlay on the ordinary sign-in, never a replacement. A tester who
had already connected that instance keeps their General token and their
preferences; the two live side by side and each request uses whichever fits:

| Request | Credential |
|---|---|
| `/api/v2` on the handed project | `projectToken` |
| `/api/v2` on any other project | the tester's General token |
| Web JSON:API (`/api/…`) | the handed `jwt`, else a session from their token |

So the project switcher stays open for a tester who has their own token, and is
pinned for one who does not — there would be nothing to reach a second project
with. When the host closes its browser and the file goes, the panel keeps working
on their own token, and Settings says whose session ended.

Saving a General token over a handed-off connection replaces it outright, session
token included.

A panel that is already open takes a new push through
`window.TestomatHandoff.apply()`, which answers
`{ok, projectId, run}` — or `{ok: false, reason: "no-offer"}`. A build without
that global predates this contract and needs updating.

With no host involved the panel logs one `ERR_FILE_NOT_FOUND` for `handoff.json`
at boot. That is the check for the file, not a fault.

## Permissions, and why each is needed

These are the permissions declared in `extension/manifest.json`. Chrome
Expand Down
51 changes: 44 additions & 7 deletions extension/api.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
// Testomat API client: Public API v2 (raw token as Bearer, flat snake_case) plus the Web-UI JSON:API
// (JWT from POST /api/login) for what v2 lacks — the v2 attachments route 404s on prod.
//
// A handed-off config (shared/handoff.js) brings both surfaces their own credential instead: a
// project token for v2, and a session token its host already holds, so there is nothing to log in
// with. It OVERLAYS the account token rather than replacing it — either may be the credential at
// any moment, hence v2Token() and login() below.

const TestomatAPI = (() => {
let cfg = null; // { baseUrl, apiToken, projectId }
let cfg = null; // { baseUrl, apiToken, projectId } (+ a handoff's projectToken/projectTokenFor)
let jwt = null; // memory-only (never chrome.storage); JSON:API + uploads
// A host app's session token. Memory-only like `jwt`, but it outlives configure(): it belongs to
// the host that launched this browser, not to whichever project the panel is pointed at.
let handedJwt = null;
let jwtUid = null; // the JWT's own `user_id` claim — memory-only like the JWT
// 'unknown' until the first login attempt, then true (session) | false (degraded).
let jwtAvailable = 'unknown';
Expand All @@ -28,6 +36,22 @@ const TestomatAPI = (() => {
readonly = 'unknown'; // re-probed against the new instance/project
}

// The host's session token, adopted by login() instead of POST /api/login. Kept apart from
// configure() so a project switch does not drop it.
function useHandoffSession(token) {
handedJwt = token || null;
jwt = null;
jwtUid = null;
jwtAvailable = 'unknown';
}

// The v2 credential: a handoff's project token while the panel is on the project it was issued
// for, else the account's General token, which reaches every project the tester can see.
function v2Token() {
if (cfg?.projectToken && cfg.projectTokenFor === cfg.projectId) return cfg.projectToken;
return cfg?.apiToken || cfg?.projectToken;
}

async function rawFetch(url, opts) {
try {
return await fetch(url, opts);
Expand All @@ -37,15 +61,16 @@ const TestomatAPI = (() => {
}

function guardConfigured() {
if (!cfg?.baseUrl || !cfg?.apiToken || !cfg?.projectId) {
if (!cfg?.baseUrl || !v2Token() || !cfg?.projectId) {
throw new ApiError('unconfigured', 0, 'Not configured');
}
}

// login + api-root routes carry no slug, so they need only the base URL + token
// (the project picker runs before any slug is known).
// login + api-root routes carry no slug, so they need only the base URL and something to open a
// session with — the account token, or the one a host handed us (the project picker runs
// before any slug is known).
function guardSession() {
if (!cfg?.baseUrl || !cfg?.apiToken) {
if (!cfg?.baseUrl || !(cfg?.apiToken || handedJwt)) {
throw new ApiError('unconfigured', 0, 'Not configured');
}
}
Expand Down Expand Up @@ -74,7 +99,7 @@ const TestomatAPI = (() => {
const res = await rawFetch(url, {
method,
headers: {
Authorization: `Bearer ${cfg.apiToken}`,
Authorization: `Bearer ${v2Token()}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
Expand Down Expand Up @@ -256,9 +281,21 @@ const TestomatAPI = (() => {
}).then((r) => r?.data);
}

// A host's session token is adopted, never exchanged — there is no account token to exchange.
// Once, though: jwtSend re-enters login() on a 401/403, and handing back the same dead token
// would both fail again and re-arm `jwtAvailable`, so nothing would ever degrade.
const HANDOFF_EXPIRED = 'The session Testeiya handed over has expired — reconnect from there';

// Lazy token→session upgrade; any failure marks the session unavailable so callers can degrade.
async function login() {
guardSession();
if (handedJwt) {
if (jwt === handedJwt) { jwtAvailable = false; throw new ApiError('auth', 401, HANDOFF_EXPIRED); }
jwt = handedJwt;
jwtUid = decodeJwtUserId(jwt);
jwtAvailable = true;
return jwt;
}
let res;
try {
res = await rawFetch(`${cfg.baseUrl}/api/login`, {
Expand Down Expand Up @@ -775,7 +812,7 @@ const TestomatAPI = (() => {
}

return {
configure, validate, listRuns, listRunGroups, countRuns, getRun, listTestruns,
configure, useHandoffSession, validate, listRuns, listRunGroups, countRuns, getRun, listTestruns,
getTestrun, getTest, getSuiteTree, getSuiteTreeOrdered, createSuite, getTestsBySuite, createTest, bulkCreateTests, updateTest,
getTestParams, setTestParams, createExample, updateExample, deleteExample,
setStatus, setStep, uploadAttachment, uploadTestAttachment,
Expand Down
1 change: 1 addition & 0 deletions extension/editor/editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
re-open the panel on top of a half-written test. -->
<script src="../shared/panel-link.js"></script>
<script src="../shared/site-access.js"></script>
<script src="../shared/handoff.js"></script>
<script src="../shared/capture-annotate.js"></script>
<script src="./annotate.js"></script>
<script src="./editor.js"></script>
Expand Down
7 changes: 4 additions & 3 deletions extension/editor/editor.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// TC Studio test page: read-only view (?test) + editor (&edit / ?suite), served both
// in the side panel and in a tab. Needs the panel's `TestomatAPI` global and OverType.

/* global TestomatAPI, OverType, Md, defaultToolbarButtons, Icons, PriorityIcons, TestType, Annotate, CaptureAnnotate, ensureSiteAccess, Tooltip, EmptyState, Sk, ImgHydrate, PanelLink */
/* global TestomatAPI, Handoff, OverType, Md, defaultToolbarButtons, Icons, PriorityIcons, TestType, Annotate, CaptureAnnotate, ensureSiteAccess, Tooltip, EmptyState, Sk, ImgHydrate, PanelLink */
(() => {
'use strict';

Expand Down Expand Up @@ -403,9 +403,10 @@
if (!hasLocal()) return RELOADED;
let settings = null;
try { ({ settings } = await chrome.storage.local.get('settings')); } catch { return RELOADED; }
if (!settings || !settings.baseUrl || !settings.apiToken || !settings.projectId) return NEED_SETUP;
if (!Handoff.credentialed(settings) || !settings.projectId) return NEED_SETUP;
activeSettings = settings;
TestomatAPI.configure(settings);
await Handoff.ready(); // a handed-off config keeps its session token in the host's file
Handoff.configure(settings);
return true;
}

Expand Down
169 changes: 169 additions & 0 deletions extension/shared/handoff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Host handoff (IIFE global `Handoff`) — how a desktop app that launched this browser with the
// extension loaded hands the panel a ready session instead of asking the tester to paste a token.
//
// The host drops `handoff.json` next to the manifest and opens the panel. A file, not a command
// line: `--load-extension` argv is readable by every process on the machine, and these are
// credentials. `chrome.runtime.getURL()` reads it back from any of our own documents.
//
// The file STAYS. `jwt` is memory-only in api.js, so a panel reload has nothing left to read
// unless the host's copy is still there; the host clears it when its browser closes. Disconnect
// cannot delete a file it does not own, so it leaves a tombstone and a newer push wins.
//
// Loaded by the panel, the editor and the viewer — all three configure the API for themselves.

/* global TestomatAPI, state, hostOf, commitSettings, openRunFromUrl, openRunsView,
parseRunUrlParts, fillSettingsForm, renderProjectBar */

const Handoff = (() => {
const FILE = 'handoff.json';
// chrome.storage.local, and deliberately outside HOST_SCOPED_KEYS: Disconnect erases those and
// reloads, and this is the one mark that has to survive that to stop the file re-connecting us.
const DECLINED_KEY = 'handoffDeclinedAt';
// chrome.storage.session — the `at` of the last run we opened, so a reload restores the tester's
// own place instead of jumping back to whatever the host last asked for.
const OPENED_KEY = 'handoffOpenedAt';

// The offer this document is running on. `undefined` until read; null when there is none.
// Held rather than re-read so `configure()` can stay synchronous — it sits on paths (a tab
// switch) where an await would let a request go out between dropping one credential and
// installing the next.
let offered;

const hasChromeStorage = () => typeof chrome !== 'undefined' && !!chrome.storage?.local;

// A settings object holds a usable credential when it carries the account's own token, or when a
// host handed one over. Every document's "is this configured" check goes through here.
const credentialed = (s) => !!(s && s.baseUrl && (s.apiToken || s.handoff));

async function readFile() {
if (typeof chrome === 'undefined' || !chrome.runtime?.getURL) return null;
let doc;
try {
const res = await fetch(chrome.runtime.getURL(FILE), { cache: 'no-store' });
if (!res.ok) return null;
doc = await res.json();
} catch {
return null; // no host, no file — the ordinary case
}
if (!doc?.baseUrl || !doc.projectId || !doc.jwt || !doc.projectToken) return null;
return { ...doc, app: String(doc.app || '').trim(), at: Number(doc.at) || 0 };
}

// A push the tester has already dismissed. Compared by `at`, so the host only has to write a
// fresher file to offer the connection again.
async function declined(h) {
if (!hasChromeStorage()) return false;
try {
const at = Number((await chrome.storage.local.get(DECLINED_KEY))[DECLINED_KEY]) || 0;
return h.at <= at;
} catch {
return false;
}
}

// Read the file once per document; `reread` is the host pushing a new one into a live panel.
async function ready(reread) {
if (offered !== undefined && !reread) return offered;
const h = await readFile();
offered = h && !(await declined(h)) ? h : null;
return offered;
}

/** The offer in hand — null until `ready()` has resolved at least once. */
const offer = () => offered || null;

// Point the API at these settings — with the host's session token when they are a handoff's.
// Every document that calls TestomatAPI.configure() calls this instead, settings of either kind:
// pointing at an account token has to CLEAR a session handed over earlier, or a Save would keep
// authenticating as whoever the host was.
function configure(settings) {
TestomatAPI.configure(settings);
const h = settings?.handoff ? offer() : null;
TestomatAPI.useHandoffSession(h?.jwt || null);
}

// ---- panel only ----------------------------------------------------------

// Adopt the offer as the active connection. Persists everything but the session token: that one
// belongs to the host, and api.js keeps it in memory alone.
async function connect() {
const h = offer();
if (!h) return null;
const host = hostOf(h.baseUrl);
if (!host) return null;
// Everything of this host's the tester owns is kept — their preferences, and their own
// General token. An offer is an overlay: when the host closes its browser and takes the file
// with it, the panel falls back to the token they pasted rather than to nothing.
const prior = state.hostSettings[host] || {};
const settings = {
...prior,
baseUrl: h.baseUrl,
projectId: h.projectId,
projectToken: h.projectToken,
// The project token opens ONE project. Named here so a switch to any other falls back to
// the account token instead of sending this one where it means nothing.
projectTokenFor: h.projectId,
handoff: true,
// Kept because the card outlives the file: a host that has closed its browser is exactly
// when the tester most needs to be told whose session just ended.
handoffApp: h.app,
};
await commitSettings(settings, host);
configure(settings);
return h;
}

// The run the host asked for, opened once per push. Runs where the panel's own run intent does,
// so the project switcher is up and openRunFromUrl has a connection to check against.
async function openRun() {
const h = offer();
if (!h?.runUrl) return false;
let opened = 0;
try {
opened = Number((await chrome.storage.session.get(OPENED_KEY))[OPENED_KEY]) || 0;
} catch { /* no session storage — opening it again beats never opening it */ }
if (h.at <= opened) return false;
try { await chrome.storage.session.set({ [OPENED_KEY]: h.at }); } catch { /* best effort */ }
// Consumed either way: a run that will not open is not worth re-trying on every reload.
return openRunFromUrl(h.runUrl);
}

// The host poking a panel that is already open. Reported back as a value, because the host is
// waiting on it and a toast inside a side panel is not an answer it can read.
async function apply() {
const h = await ready(true);
if (!h) return { ok: false, reason: 'no-offer' };
await connect();
fillSettingsForm();
renderProjectBar();
// A panel the host has only just opened consumed the push at boot, so openRun() answers false
// for the very run it is showing. The host asked whether its run is up — answer that.
const run = (await openRun()) || showingRun(h);
// The connect screen has no tabs to leave by, so a panel that was never signed in has to be
// moved off it — where a Save lands, when the host named no run.
if (!run && state.view === 'settings') openRunsView();
return { ok: true, projectId: h.projectId, run };
}

// Is the panel already on the run this offer names — opened by its own boot, or still open from
// an earlier push the tester never left.
function showingRun(h) {
if (!h.runUrl || (state.view !== 'run' && state.view !== 'test')) return false;
const parts = parseRunUrlParts(h.runUrl);
return !!parts && parts.kind === 'run' && String(state.runId) === String(parts.id);
}

// Disconnect, for a connection the panel did not choose: the file is the host's to delete, so
// this marks the offer as answered instead.
async function decline() {
const h = await readFile();
if (!h || !hasChromeStorage()) return;
try { await chrome.storage.local.set({ [DECLINED_KEY]: h.at }); } catch { /* best effort */ }
offered = null;
}

return { ready, offer, credentialed, configure, connect, openRun, apply, decline };
})();

// The host's entry point into a panel that is already open (a fresh one picks the file up at boot).
if (typeof window !== 'undefined') window.TestomatHandoff = Handoff;
Loading