c1d0eb3891
Same discriminant as the Android client - "/api/v1/repos/" in the URL - so one address tells both ends which transport to speak, and nothing new appears in Settings beyond a placeholder and a warning. The bridge stays shared. Three things differ, and they are confined: the header is "token <PAT>" rather than Basic (Gitea answers a silent 401 to the other), a PUT carries a JSON envelope with the file base64-encoded inside it, and the connection test uses GET because the contents API does not answer HEAD. The sha does the ETag's whole job. It comes back from the pull, rides in the PUT body instead of an If-Match, and a stale one earns a 409 - handled exactly like WebDAV's 412, by re-running pull->merge->push so the other device's changes are folded in rather than clobbered. On a first push `sha` is OMITTED rather than sent empty: Gitea reads its presence as "replace that exact version". `branch` is omitted too, so the repo's default is used instead of a name we would be guessing. A failing PUT now reads its response body: Gitea explains a 409 or a 422 there, and without it the error was just a number. The test button checks the SHAPE of what came back, not only the status. A URL one path segment off answers 200 with valid JSON that is not a file - which would otherwise surface much later as "wrong sync password" and send you looking on the wrong side. Four tests cover that parsing, including the line-wrapped base64 Gitea sends and the array an accidental directory URL returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
167 lines
7.2 KiB
JavaScript
167 lines
7.2 KiB
JavaScript
// ============================================================
|
|
// Test harness — load js/app.js into a sandboxed VM context
|
|
// ============================================================
|
|
//
|
|
// app.js is a ~12k-line browser monofile with no module exports. It runs
|
|
// only one top-level statement (a DOMContentLoaded listener); everything
|
|
// else is function/const declarations. We load it in a node:vm context with
|
|
// browser globals stubbed out so init() never fires, then reach the internals
|
|
// we want to test through an appended export epilogue.
|
|
//
|
|
// Why the epilogue: in vm.runInContext, top-level `function`/`var` declarations
|
|
// attach to the context's global object, but top-level `const`/`let` (like
|
|
// `state`, `API`, `HASH_ALGO_V2`) do NOT. So we append a line that copies the
|
|
// symbols we care about onto globalThis.__test, giving tests a stable handle.
|
|
//
|
|
// Reassignable seams for the merge tests: `api`, `loadEntries`, `loadFolders`,
|
|
// `encryptImportEntry`, etc. are `function` declarations → global properties,
|
|
// so a test can override `ctx.api = fake` and the free-variable lookup inside
|
|
// applyRemoteSnapshot will pick up the fake. `state` is a `const` (lexical),
|
|
// so it can't be replaced — but it CAN be mutated, and applyRemoteSnapshot
|
|
// closes over that same object, so mutating ctx.__test.state is visible to it.
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const { webcrypto } = require('node:crypto');
|
|
|
|
// The frontend is split into ordered classic-script files (§3.1). In the
|
|
// browser they share one global lexical environment; node:vm does NOT share
|
|
// top-level const/let across separate runInContext calls, so we CONCATENATE
|
|
// the app.* parts (in <script> load order) into one script. argon2.js is a
|
|
// self-contained IIFE and loads separately (see below).
|
|
const APP_PARTS = ['app.crypto.js', 'app.totp.js', 'app.favicon.js', 'app.import.js', 'app.backup.js', 'app.health.js', 'app.overlays.js', 'app.attachments.js', 'app.autofill.js', 'app.js', 'app.unlock.js', 'app.sync.js'].map(f => path.join(__dirname, '..', f));
|
|
|
|
// In-memory Storage stub (Web Storage API surface used by app.js).
|
|
function makeStorage() {
|
|
const m = new Map();
|
|
return {
|
|
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
setItem: (k, v) => { m.set(k, String(v)); },
|
|
removeItem: (k) => { m.delete(k); },
|
|
clear: () => m.clear(),
|
|
key: (i) => Array.from(m.keys())[i] ?? null,
|
|
get length() { return m.size; },
|
|
};
|
|
}
|
|
|
|
// Minimal no-throw DOM/window stubs. app.js only *executes* one DOM call at
|
|
// load (document.addEventListener for DOMContentLoaded) — everything else is
|
|
// inside functions we don't call. So these just have to exist and not throw.
|
|
function makeDomStubs() {
|
|
const noop = () => {};
|
|
const elStub = new Proxy({}, {
|
|
get: (_t, prop) => {
|
|
if (prop === 'style') return {};
|
|
if (prop === 'classList') return { add: noop, remove: noop, toggle: noop, contains: () => false };
|
|
if (prop === 'addEventListener' || prop === 'removeEventListener') return noop;
|
|
if (prop === 'appendChild' || prop === 'append' || prop === 'remove') return noop;
|
|
if (prop === 'setAttribute' || prop === 'removeAttribute') return noop;
|
|
if (prop === 'querySelector') return () => null;
|
|
if (prop === 'querySelectorAll') return () => [];
|
|
return undefined;
|
|
},
|
|
set: () => true,
|
|
});
|
|
const document = {
|
|
addEventListener: noop,
|
|
removeEventListener: noop,
|
|
getElementById: () => null,
|
|
querySelector: () => null,
|
|
querySelectorAll: () => [],
|
|
createElement: () => elStub,
|
|
body: elStub,
|
|
documentElement: elStub,
|
|
};
|
|
return { document, elStub, noop };
|
|
}
|
|
|
|
// Build a fresh sandbox + load app.js into it. Returns the contextified
|
|
// sandbox; test internals live on ctx.__test.
|
|
function loadApp(overrides = {}) {
|
|
const { document, noop } = makeDomStubs();
|
|
|
|
const sandbox = {
|
|
crypto: webcrypto,
|
|
TextEncoder,
|
|
TextDecoder,
|
|
btoa,
|
|
atob,
|
|
console,
|
|
setTimeout,
|
|
clearTimeout,
|
|
setInterval,
|
|
clearInterval,
|
|
Date,
|
|
JSON,
|
|
Math,
|
|
Promise,
|
|
URL,
|
|
URLSearchParams,
|
|
// Browser-ish globals used at load time
|
|
location: { pathname: '/index.html', href: 'http://127.0.0.1/index.html', search: '', hash: '' },
|
|
history: { replaceState: noop, pushState: noop },
|
|
navigator: { clipboard: { writeText: async () => {}, readText: async () => '' }, userAgent: 'node-test' },
|
|
localStorage: makeStorage(),
|
|
sessionStorage: makeStorage(),
|
|
document,
|
|
fetch: async () => { throw new Error('fetch not stubbed'); },
|
|
// Some code paths reference matchMedia / requestAnimationFrame
|
|
matchMedia: () => ({ matches: false, addEventListener: noop, addListener: noop }),
|
|
requestAnimationFrame: (cb) => setTimeout(cb, 0),
|
|
...overrides,
|
|
};
|
|
// window / self / globalThis self-reference (app.js reads window.location etc.)
|
|
sandbox.window = sandbox;
|
|
sandbox.self = sandbox;
|
|
sandbox.globalThis = sandbox;
|
|
|
|
vm.createContext(sandbox);
|
|
|
|
// Load the vendored Argon2 bundle first (index.html loads it before
|
|
// app.js). It assigns globalThis.NobleArgon2 — needed by the argon2id
|
|
// KDF branch in deriveKeyBytes.
|
|
const ARGON2_JS = path.join(__dirname, '..', 'argon2.js');
|
|
vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' });
|
|
|
|
// Concatenate the app.* parts in load order (see APP_PARTS). Newline
|
|
// separators keep line-based errors legible; shared global scope is
|
|
// preserved because it's a single script run.
|
|
let src = APP_PARTS.map(p => fs.readFileSync(p, 'utf8')).join('\n;\n');
|
|
|
|
// Export epilogue — surface the lexical (const) symbols we test, plus a
|
|
// couple of function-decl seams for convenience. Kept in one place so the
|
|
// list of "what tests can touch" is explicit.
|
|
src += `
|
|
;globalThis.__test = {
|
|
state,
|
|
// crypto
|
|
bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined),
|
|
verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier,
|
|
deriveKeyBytes, isDecoupledVerifierAlgo,
|
|
encryptPwd, decryptPwd, sha256Hex,
|
|
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
|
|
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
|
|
// totp
|
|
base32Decode, generateTOTP, parseOtpAuthUri,
|
|
// favicon
|
|
faviconHost,
|
|
// csv / import
|
|
parseCSV, findColumn, parseEntriesFromCSV, normalizeImportTimestamp,
|
|
// strength
|
|
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
|
|
// merge (async, coupled — tests stub the io seams below)
|
|
applyRemoteSnapshot, buildSyncSnapshot,
|
|
// gitea transport
|
|
syncIsGitea, giteaUnwrap,
|
|
// metadata-at-rest
|
|
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
|
};
|
|
`;
|
|
|
|
vm.runInContext(src, sandbox, { filename: 'app.js' });
|
|
return sandbox;
|
|
}
|
|
|
|
module.exports = { loadApp, makeStorage };
|