(yhub) add more tests on yhub-server using vitest

We use vitest in most of our applications,
to ensure consistency and maintainability across our
codebase we are now using vitest for yhub-server as well.
We added more tests on yhub-server.
The tests cover migration, permissions, and server
functionality. We also added a helper file for test
utilities.
This commit is contained in:
Anthony LC
2026-09-21 14:47:59 +02:00
committed by Manuel Raynaud
parent fe8d7aaa0f
commit d295a08fe8
9 changed files with 2811 additions and 13 deletions
+28 -2
View File
@@ -100,8 +100,8 @@ objects**: the auth plugin answers, per facet, what a subject may do with one
document, and yhub enforces every facet itself — on the websocket and on the
REST routes alike. Docs' whole policy is three tables in `permissions.js`, kept
out of `server.js` so they can be read and tested without redis and postgres.
`permissions.test.js` asks them the same questions yhub's gates ask; run it with
`npm test`.
`__tests__/permissions.test.js` asks them the same questions yhub's gates ask;
run it with `npm test` (see "Tests" below).
Masks are positional `crud` strings where `-` denies, so `'-r--'` is read-only.
@@ -380,6 +380,32 @@ made *versioned*, so what is exercised is what a deployment runs rather than a
simpler case. Watch it with `mc ls --versions --recursive impress/yhub-storage`
from an `mc` container on the stack's network.
## Tests
`npm test` runs two suites, neither of which needs redis, postgres or S3:
- `__tests__/permissions.test.js` on node's own runner (`node:test`) — it
imports `@y/hub/permissions` (a subpath export, no redis/postgres pulled in)
to run the real permission pipeline, and is kept on `node:test` on purpose,
- the `__tests__/*.spec.mjs` files on **vitest** (`npm run test:watch` for the
watcher):
- `__tests__/migration.spec.mjs` drives `maybeMigrate` and `fullMigrate` end
to end with `@aws-sdk/client-s3` and the yhub instance faked and `@y/y`
real, so the content maps under assertion are the real ones — plus the
module-load validation (`SOFT_MIGRATION`, the `LEGACY_S3_*` checks,
addressing style),
- `__tests__/server.spec.mjs` covers the boot contract: the environment
`server.js` refuses (`YHUB_ROLE`, the numeric knobs, a half-configured
bucket) and the configuration it hands `createYHub` when it accepts,
- `__tests__/permissions.spec.mjs` is the vitest counterpart of
`permissions.test.js`.
The `__tests__/` directory (and the `.mjs` extension) keeps the specs out of
`node --test`'s discovery and out of the Docker image (`COPY *.js`); shared
fakes live in `__tests__/_helpers.mjs`. CI does not run these yet — it installs with `npm ci --omit=dev`, so `vitest` is absent
there; the pytest-driven integration suite in `.github/workflows/impress.yml` is
what exercises a real collaboration server.
## Container image
The `Dockerfile` has two final stages, like the other services of this
+125
View File
@@ -0,0 +1,125 @@
// Shared fixtures and fakes for the vitest specs. Named `.mjs` (not `.js`) so
// neither the Dockerfile's `COPY *.js` nor `node --test` ever picks it up, and
// `*.spec.mjs` so the same is true of the spec files themselves.
import { vi } from 'vitest';
import * as Y from '@y/y';
// --- Yjs fixtures ---------------------------------------------------------
// A raw Yjs update (what the legacy S3 object base64-decodes to) for a text
// document holding `text`. Deterministic: one client, one insert.
export const makeUpdate = (text = 'hello world') => {
const doc = new Y.Doc();
doc.get('t', 'text').insert(0, text);
return Y.encodeStateAsUpdate(doc);
};
// A sequence of *full snapshots* of one lineage, the shape `{docid}/file`'s S3
// versions have: each element is `encodeStateAsUpdate` after one more edit, so
// applying them in order to a gc:false doc yields fresh content ids per step.
export const makeVersionChain = (steps = ['a', 'bc', 'def']) => {
const doc = new Y.Doc();
const text = doc.get('t', 'text');
const updates = [];
for (const step of steps) {
text.insert(text.length, step);
updates.push(Y.encodeStateAsUpdate(doc));
}
return updates;
};
export const toBase64 = (update) => Buffer.from(update).toString('base64');
// What `fetchLegacyDoc` reads back: a Body with `transformToString`.
export const s3Body = (update) => ({
transformToString: async () => toBase64(update),
});
// A GetObject rejection the sdk would raise for an absent key/version.
export const s3NotFound = (name = 'NoSuchKey') =>
Object.assign(new Error(name), { name });
// --- a minimal in-memory redis -----------------------------------------
// Enough of the client for the lock dance and the migrated set. `set` honors
// the `NX` condition migrate.js relies on; `eval` emulates the compare-and-
// delete lua script it releases the lock with.
export const fakeRedis = () => {
const kv = new Map();
const sets = new Map();
const setOf = (k) => sets.get(k) ?? sets.set(k, new Set()).get(k);
return {
kv,
sets,
sIsMember: vi.fn(async (k, m) => setOf(k).has(m)),
sAdd: vi.fn(async (k, m) => void setOf(k).add(m)),
set: vi.fn(async (k, v, opts) => {
if (opts?.condition === 'NX' && kv.has(k)) return null;
kv.set(k, v);
return 'OK';
}),
exists: vi.fn(async (k) => (kv.has(k) ? 1 : 0)),
del: vi.fn(async (k) => void kv.delete(k)),
eval: vi.fn(async (_script, { keys, arguments: args }) => {
if (kv.get(keys[0]) === args[0]) {
kv.delete(keys[0]);
return 1;
}
return 0;
}),
};
};
// --- a fake yhub instance ---------------------------------------------
// The surface migration.js touches, all as vi.fns with harmless defaults. Pass
// overrides for the handful a given test drives; reach into `.stream.redis`
// (a fakeRedis) for the lock/set state.
export const makeYhub = (overrides = {}) => {
const redis = overrides.redis ?? fakeRedis();
const yhub = {
stream: {
prefix: 'yhub',
redis,
// ydocExists: no messages on the stream by default
getMessages: vi.fn(async () => [{ messages: [] }]),
addMessage: vi.fn(async () => {}),
},
persistence: {
// ydocExists: lastClock '0' means "yhub does not know this room"
retrieveDoc: vi.fn(async () => ({ lastClock: '0' })),
store: vi.fn(async () => {}),
},
computePool: {
// fullMigrate merges the nongc snapshot into a gc one; hand a marker back
mergeUpdates: vi.fn(async (_gc, [update]) => update),
},
};
return deepAssign(yhub, overrides);
};
const deepAssign = (target, source) => {
for (const [key, value] of Object.entries(source)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
typeof value !== 'function' &&
target[key]
) {
deepAssign(target[key], value);
} else {
target[key] = value;
}
}
return target;
};
// A stand-in for `@y/hub`'s pino logger: swallow the lines, keep the spies.
export const fakeLoggerModule = () => {
const record = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const child = () => ({ ...record, child });
return { logger: { ...record, child } };
};
@@ -0,0 +1,618 @@
// migration.js — the legacy Django/S3 document store reader.
//
// The two entry points server.js calls are exercised end to end here:
// maybeMigrate — the lazy seed on first access to an unknown room
// fullMigrate — the version-history backfill
// with `@aws-sdk/client-s3` and the yhub instance faked, and `@y/y` real, so the
// content maps these build are the real ones.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from '@y/y';
import {
makeUpdate,
makeVersionChain,
makeYhub,
fakeRedis,
s3Body,
s3NotFound,
} from './_helpers.mjs';
// migration.js reads these once, at import time, and builds its S3 client from
// them. Set before the module is ever imported (below), overriding the floor
// vitest.config.mts puts down.
process.env.SOFT_MIGRATION = 'true';
process.env.LEGACY_S3_ENDPOINT_URL = 'https://legacy.example.test';
process.env.LEGACY_S3_ACCESS_KEY_ID = 'legacy-key';
process.env.LEGACY_S3_SECRET_ACCESS_KEY = 'legacy-secret';
process.env.LEGACY_S3_BUCKET_NAME = 'legacy-media';
delete process.env.LEGACY_S3_SIGNATURE_VERSION;
delete process.env.LEGACY_S3_REGION_NAME;
// One send() spy for the whole file; every S3Client the module builds routes
// here. `s3configs` captures each client's constructor config.
const { s3send, s3configs } = vi.hoisted(() => ({
s3send: vi.fn(),
s3configs: [],
}));
vi.mock('@aws-sdk/client-s3', () => ({
S3Client: class {
constructor(config) {
s3configs.push(config);
}
send(command, options) {
return s3send(command, options);
}
},
GetObjectCommand: class {
constructor(input) {
this.input = input;
this.kind = 'get';
}
},
ListObjectVersionsCommand: class {
constructor(input) {
this.input = input;
this.kind = 'list';
}
},
}));
vi.mock('@y/hub', () => {
const rec = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const child = () => ({ ...rec, child });
return { logger: { ...rec, child } };
});
const load = () => import('../migration.js');
const docRef = (docid = 'doc-1') => ({ org: 'docs', docid, branch: 'main' });
const LOCK_KEY = 'yhub:softmigrate:docs:doc-1:main';
const MIGRATED_SET = 'yhub:migrated:v1';
// Route S3 by command. `getByVersion` maps a VersionId to a Body (or an Error to
// throw); `get` handles the versionless "newest object" read; `list` is the
// ListObjectVersions response (or a function of its input).
const routeS3 = ({ getByVersion, get, list } = {}) => {
s3send.mockImplementation(async (command) => {
if (command.kind === 'get') {
const versionId = command.input.VersionId;
if (versionId != null && getByVersion) {
if (!(versionId in getByVersion)) throw s3NotFound('NoSuchVersion');
const entry = getByVersion[versionId];
if (entry instanceof Error) throw entry;
return { Body: entry };
}
if (get) return get(command.input);
return { Body: s3Body(makeUpdate()) };
}
if (command.kind === 'list') {
return typeof list === 'function'
? list(command.input)
: (list ?? { Versions: [], IsTruncated: false });
}
throw new Error(`unexpected S3 command: ${command.kind}`);
});
};
// The [name, value] attribute pairs a stored/seeded content map carries.
const attrsOf = (contentmapBytes) => {
const decoded = Y.decodeContentMap(contentmapBytes);
const out = { inserts: [], deletes: [] };
for (const side of ['inserts', 'deletes']) {
for (const [, entry] of decoded[side].clients) {
for (const id of entry._ids) {
for (const attr of id.attrs) out[side].push([attr.name, attr.val]);
}
}
}
return out;
};
beforeEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
s3send.mockReset();
s3configs.length = 0;
});
describe('module load', () => {
it('refuses to boot when SOFT_MIGRATION is set without credentials', async () => {
vi.stubEnv('LEGACY_S3_ACCESS_KEY_ID', '');
vi.resetModules();
await expect(load()).rejects.toThrow(/requires LEGACY_S3_/);
});
it('refuses an endpoint URL that carries a path', async () => {
vi.stubEnv('LEGACY_S3_ENDPOINT_URL', 'https://legacy.example.test/media');
vi.resetModules();
await expect(load()).rejects.toThrow(/must not contain a path/);
});
it('refuses a SigV2 signature version', async () => {
vi.stubEnv('LEGACY_S3_SIGNATURE_VERSION', 's3');
vi.resetModules();
await expect(load()).rejects.toThrow(
/LEGACY_S3_SIGNATURE_VERSION must be one of/,
);
});
it('accepts the v4 spelling of the signature version', async () => {
vi.stubEnv('LEGACY_S3_SIGNATURE_VERSION', 'v4');
vi.resetModules();
const mod = await load();
expect(mod.SOFT_MIGRATION).toBe(true);
});
it('addresses a self-hosted endpoint path-style and defaults the region', async () => {
vi.resetModules();
await load();
const config = s3configs.at(-1);
expect(config.forcePathStyle).toBe(true);
expect(config.region).toBe('us-east-1');
expect(config.authSchemePreference).toEqual(['aws.auth#sigv4']);
});
it('addresses an amazonaws.com endpoint virtual-host-style', async () => {
vi.stubEnv('LEGACY_S3_ENDPOINT_URL', 'https://s3.eu-west-3.amazonaws.com');
vi.resetModules();
await load();
expect(s3configs.at(-1).forcePathStyle).toBe(false);
});
it('builds no S3 client when SOFT_MIGRATION is off', async () => {
vi.stubEnv('SOFT_MIGRATION', 'false');
vi.resetModules();
const mod = await load();
expect(mod.SOFT_MIGRATION).toBe(false);
expect(s3configs).toHaveLength(0);
});
});
describe('isPermanentFailure', () => {
it('is true only for an error marked permanent', async () => {
const { isPermanentFailure } = await load();
expect(isPermanentFailure(Object.assign(new Error(), { permanent: true }))).toBe(
true,
);
expect(isPermanentFailure(new Error('network'))).toBe(false);
expect(isPermanentFailure(undefined)).toBe(false);
});
});
describe('maybeMigrate — the lazy seed', () => {
it('seeds an unknown room from the newest legacy object', async () => {
const update = makeUpdate('hello world');
routeS3({ get: () => ({ Body: s3Body(update) }) });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await maybeMigrate(yhub, docRef());
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
const [ref, message] = yhub.stream.addMessage.mock.calls[0];
expect(ref).toEqual(docRef());
expect(message.type).toBe('ydoc:update:v1');
expect(Buffer.from(message.update)).toEqual(Buffer.from(update));
expect(message.contentmap).toBeInstanceOf(Uint8Array);
const get = s3send.mock.calls.find(([c]) => c.kind === 'get')[0];
expect(get.input).toEqual({ Bucket: 'legacy-media', Key: 'doc-1/file' });
});
it('attributes the seed to system/s3 and stamps no timestamp', async () => {
routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await maybeMigrate(yhub, docRef());
const { contentmap } = yhub.stream.addMessage.mock.calls[0][1];
const { inserts } = attrsOf(contentmap);
expect(inserts).toContainEqual(['insert', 'system']);
expect(inserts).toContainEqual(['insert:migration', 's3']);
expect(inserts.map(([name]) => name)).not.toContain('insertAt');
});
it('does not seed a room yhub already persisted', async () => {
routeS3();
const { maybeMigrate } = await load();
const yhub = makeYhub({
persistence: { retrieveDoc: vi.fn(async () => ({ lastClock: '7' })) },
});
await maybeMigrate(yhub, docRef());
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
expect(s3send).not.toHaveBeenCalled();
});
it('does not seed a room that already has an update on the stream', async () => {
routeS3();
const { maybeMigrate } = await load();
const yhub = makeYhub({
stream: {
getMessages: vi.fn(async () => [
{ messages: [{ type: 'ydoc:update:v1' }] },
]),
},
});
await maybeMigrate(yhub, docRef());
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
it('ignores non-content messages when probing the stream', async () => {
routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) });
const { maybeMigrate } = await load();
const yhub = makeYhub({
stream: {
getMessages: vi.fn(async () => [
{ messages: [{ type: 'awareness:v1' }, { type: 'auth-check:v1' }] },
]),
},
});
await maybeMigrate(yhub, docRef());
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
});
it.each(['NoSuchKey', 'NoSuchVersion', 'NotFound'])(
'treats a %s from S3 as an empty room, not an error',
async (name) => {
routeS3({ get: () => { throw s3NotFound(name); } });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await expect(maybeMigrate(yhub, docRef())).resolves.toBeUndefined();
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
},
);
it('rejects, permanently, a legacy object that is not a valid Yjs update', async () => {
routeS3({ get: () => ({ Body: s3Body(new Uint8Array([1, 2, 3, 4, 5, 6])) }) });
const { maybeMigrate, isPermanentFailure } = await load();
const yhub = makeYhub();
const err = await maybeMigrate(yhub, docRef()).catch((e) => e);
expect(isPermanentFailure(err)).toBe(true);
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
it('caches a permanent failure: a second access does not re-fetch S3', async () => {
routeS3({ get: () => ({ Body: s3Body(new Uint8Array([9, 9, 9, 9, 9, 9])) }) });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await maybeMigrate(yhub, docRef()).catch(() => {});
await maybeMigrate(yhub, docRef()).catch(() => {});
expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1);
});
it('caches the empty verdict: a second access does not re-fetch S3', async () => {
routeS3({ get: () => { throw s3NotFound('NoSuchKey'); } });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await maybeMigrate(yhub, docRef());
await maybeMigrate(yhub, docRef());
expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1);
});
it('reports a network error as retryable, not permanent', async () => {
routeS3({ get: () => { throw new Error('ECONNRESET'); } });
const { maybeMigrate, isPermanentFailure } = await load();
const yhub = makeYhub();
const err = await maybeMigrate(yhub, docRef()).catch((e) => e);
expect(isPermanentFailure(err)).toBe(false);
});
it('collapses concurrent first-connections to one S3 fetch and one seed', async () => {
routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) });
const { maybeMigrate } = await load();
const yhub = makeYhub();
await Promise.all([
maybeMigrate(yhub, docRef()),
maybeMigrate(yhub, docRef()),
maybeMigrate(yhub, docRef()),
]);
expect(s3send.mock.calls.filter(([c]) => c.kind === 'get')).toHaveLength(1);
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
});
it('releases its own migrate lock with a compare-and-delete', async () => {
routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) });
const { maybeMigrate } = await load();
const redis = fakeRedis();
const yhub = makeYhub({ redis });
await maybeMigrate(yhub, docRef());
expect(redis.eval).toHaveBeenCalledTimes(1);
expect(redis.kv.has(LOCK_KEY)).toBe(false);
});
it('seeds itself when another seeder held the lock but left the room empty', async () => {
routeS3({ get: () => ({ Body: s3Body(makeUpdate()) }) });
const { maybeMigrate } = await load();
const redis = fakeRedis();
redis.kv.set(LOCK_KEY, 'another-replica');
// the other seeder's lock has already gone by the time we re-probe
redis.exists = vi.fn(async () => 0);
const yhub = makeYhub({ redis });
await maybeMigrate(yhub, docRef());
expect(yhub.stream.addMessage).toHaveBeenCalledTimes(1);
// acquired nothing, so it must not touch the other replica's lock
expect(redis.eval).not.toHaveBeenCalled();
});
it('skips the S3 round-trip for a fully migrated room', async () => {
routeS3();
const { maybeMigrate } = await load();
const redis = fakeRedis();
await redis.sAdd(MIGRATED_SET, 'doc-1');
const yhub = makeYhub({ redis });
await maybeMigrate(yhub, docRef());
expect(s3send).not.toHaveBeenCalled();
expect(yhub.stream.addMessage).not.toHaveBeenCalled();
});
});
describe('fullMigrate — the version-history backfill', () => {
const listNewestFirst = (entries) => ({
Versions: entries.map(({ versionId, ms }) => ({
Key: 'doc-1/file',
VersionId: versionId,
LastModified: new Date(ms),
})),
IsTruncated: false,
});
it('reports an empty status when there is no legacy object', async () => {
routeS3({ list: { Versions: [], IsTruncated: false } });
const { fullMigrate } = await load();
const yhub = makeYhub();
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({ status: 'empty', versions: 0 });
expect(yhub.persistence.store).not.toHaveBeenCalled();
});
it('replays every version oldest-first into one clock-0 row', async () => {
const [v1, v2] = makeVersionChain(['a', 'bc']);
routeS3({
list: listNewestFirst([
{ versionId: 'ver-2', ms: 2000 },
{ versionId: 'ver-1', ms: 1000 },
]),
getByVersion: { 'ver-1': s3Body(v1), 'ver-2': s3Body(v2) },
});
const { fullMigrate } = await load();
const redis = fakeRedis();
const yhub = makeYhub({ redis });
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({
status: 'ok',
versions: 2,
applied: 2,
skipped: 0,
dropped: 0,
});
// fetched in ascending-timestamp order, not the order S3 listed them
const fetchedVersionIds = s3send.mock.calls
.filter(([c]) => c.kind === 'get')
.map(([c]) => c.input.VersionId);
expect(fetchedVersionIds).toEqual(['ver-1', 'ver-2']);
expect(yhub.persistence.store).toHaveBeenCalledTimes(1);
const [ref, row] = yhub.persistence.store.mock.calls[0];
expect(ref).toEqual(docRef());
expect(row.lastClock).toBe('0');
expect(row.contentmap).toBeInstanceOf(Uint8Array);
expect(row.contentids).toBeInstanceOf(Uint8Array);
expect(redis.sets.get(MIGRATED_SET)).toEqual(new Set(['doc-1']));
});
it("stamps each version's content with that version's S3 timestamp", async () => {
const [v1, v2] = makeVersionChain(['a', 'bc']);
routeS3({
list: listNewestFirst([
{ versionId: 'ver-2', ms: 2000 },
{ versionId: 'ver-1', ms: 1000 },
]),
getByVersion: { 'ver-1': s3Body(v1), 'ver-2': s3Body(v2) },
});
const { fullMigrate } = await load();
const yhub = makeYhub();
await fullMigrate(yhub, docRef());
const { inserts } = attrsOf(yhub.persistence.store.mock.calls[0][1].contentmap);
expect(inserts).toContainEqual(['insert', 'system']);
expect(inserts).toContainEqual(['insertAt', 1000]);
expect(inserts).toContainEqual(['insertAt', 2000]);
});
it('leaves a document already in the migrated set untouched', async () => {
routeS3();
const { fullMigrate } = await load();
const redis = fakeRedis();
await redis.sAdd(MIGRATED_SET, 'doc-1');
const yhub = makeYhub({ redis });
const result = await fullMigrate(yhub, docRef());
expect(result).toEqual({ status: 'already' });
expect(s3send).not.toHaveBeenCalled();
});
it('force replays a document that is already in the set', async () => {
routeS3({
list: listNewestFirst([{ versionId: 'ver-1', ms: 1000 }]),
getByVersion: { 'ver-1': s3Body(makeUpdate()) },
});
const { fullMigrate } = await load();
const redis = fakeRedis();
await redis.sAdd(MIGRATED_SET, 'doc-1');
const yhub = makeYhub({ redis });
const result = await fullMigrate(yhub, docRef(), { force: true });
expect(result.status).toBe('ok');
expect(yhub.persistence.store).toHaveBeenCalledTimes(1);
});
it('skips a corrupt version and keeps going', async () => {
const [, v2] = makeVersionChain(['a', 'bc']);
routeS3({
list: listNewestFirst([
{ versionId: 'ver-2', ms: 2000 },
{ versionId: 'ver-1', ms: 1000 },
]),
getByVersion: {
'ver-1': s3Body(new Uint8Array([1, 2, 3, 4, 5])),
'ver-2': s3Body(v2),
},
});
const { fullMigrate } = await load();
const yhub = makeYhub();
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({ status: 'ok', applied: 1, skipped: 1 });
});
it('reports "nothing" and does not remember a document with no readable version', async () => {
routeS3({
list: listNewestFirst([
{ versionId: 'ver-2', ms: 2000 },
{ versionId: 'ver-1', ms: 1000 },
]),
getByVersion: {
'ver-1': s3Body(new Uint8Array([1, 2, 3])),
'ver-2': s3Body(new Uint8Array([4, 5, 6])),
},
});
const { fullMigrate } = await load();
const redis = fakeRedis();
const yhub = makeYhub({ redis });
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({ status: 'nothing', applied: 0, skipped: 2 });
expect(yhub.persistence.store).not.toHaveBeenCalled();
expect(redis.sets.get(MIGRATED_SET)?.has('doc-1')).toBeFalsy();
});
it('silently skips a version that vanished between listing and read', async () => {
const [, v2] = makeVersionChain(['a', 'bc']);
routeS3({
list: listNewestFirst([
{ versionId: 'ver-2', ms: 2000 },
{ versionId: 'ver-1', ms: 1000 },
]),
getByVersion: {
'ver-1': s3NotFound('NoSuchVersion'),
'ver-2': s3Body(v2),
},
});
const { fullMigrate } = await load();
const yhub = makeYhub();
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({ status: 'ok', applied: 1, skipped: 0 });
});
it('follows the version listing across pages', async () => {
const [v1, v2, v3] = makeVersionChain(['a', 'bc', 'def']);
const pages = [
{
Versions: [
{ Key: 'doc-1/file', VersionId: 'ver-3', LastModified: new Date(3000) },
],
IsTruncated: true,
NextKeyMarker: 'key-marker',
NextVersionIdMarker: 'version-marker',
},
{
Versions: [
{ Key: 'doc-1/file', VersionId: 'ver-2', LastModified: new Date(2000) },
{ Key: 'doc-1/file', VersionId: 'ver-1', LastModified: new Date(1000) },
],
IsTruncated: false,
},
];
let call = 0;
routeS3({
list: (input) => {
const page = pages[call++];
if (call === 2) {
expect(input.KeyMarker).toBe('key-marker');
expect(input.VersionIdMarker).toBe('version-marker');
}
return page;
},
getByVersion: {
'ver-1': s3Body(v1),
'ver-2': s3Body(v2),
'ver-3': s3Body(v3),
},
});
const { fullMigrate } = await load();
const yhub = makeYhub();
const result = await fullMigrate(yhub, docRef());
expect(call).toBe(2);
expect(result).toMatchObject({ status: 'ok', versions: 3, applied: 3 });
});
it('drops the oldest versions past the 500 cap', async () => {
const update = makeUpdate('constant');
const entries = Array.from({ length: 501 }, (_, i) => ({
versionId: `ver-${i}`,
ms: 1000 + i,
}));
routeS3({
list: listNewestFirst(entries),
getByVersion: Object.fromEntries(
entries.map(({ versionId }) => [versionId, s3Body(update)]),
),
});
const { fullMigrate } = await load();
const yhub = makeYhub();
const result = await fullMigrate(yhub, docRef());
expect(result).toMatchObject({ versions: 500, dropped: 1 });
});
it('rejects when the version listing itself fails', async () => {
routeS3({ list: () => { throw new Error('S3 is down'); } });
const { fullMigrate } = await load();
const yhub = makeYhub();
await expect(fullMigrate(yhub, docRef())).rejects.toThrow('S3 is down');
const { logger } = await import('@y/hub');
expect(logger.error).toHaveBeenCalled();
});
});
@@ -0,0 +1,273 @@
// permissions.js — Docs' access policy as yhub permission objects.
//
// `permissions.test.js` (node:test) covers the same ground reaching into
// `@y/hub` by path; this is the vitest-suite counterpart, phrased as the
// questions yhub's gates ask (`hasPermissions`) rather than as a snapshot of
// the tables — a mask may be respelled, but it may not start answering
// differently.
import { describe, expect, it } from 'vitest';
import {
createDocumentPermissions,
hasPermissions,
normalizePermissions,
} from '@y/hub/permissions';
import {
adminDocumentPermissions,
browserDocumentPermissions,
publicGlobalPermissions,
resolveHistoryFrom,
} from '../permissions.js';
const ACCESS_SINCE = 1_700_000_000_000;
const reader = normalizePermissions(browserDocumentPermissions(false, ACCESS_SINCE));
const editor = normalizePermissions(browserDocumentPermissions(true, ACCESS_SINCE));
const linkReader = normalizePermissions(browserDocumentPermissions(false));
const linkEditor = normalizePermissions(browserDocumentPermissions(true));
const admin = normalizePermissions(adminDocumentPermissions);
const grants = (permissions, required) =>
hasPermissions(permissions, createDocumentPermissions(required));
describe('presence', () => {
it('lets a reader receive presence but never broadcast it', () => {
expect(grants(reader, { awareness: '-r--' })).toBe(true);
expect(grants(reader, { awareness: '--u-' })).toBe(false);
});
it('lets an editor broadcast presence', () => {
expect(grants(editor, { awareness: '--u-' })).toBe(true);
});
});
describe('the document and its transports', () => {
it('lets a reader read but not write, on either transport', () => {
expect(grants(reader, { ydoc: '-r--' })).toBe(true);
expect(grants(reader, { ydoc: '--u-' })).toBe(false);
expect(grants(reader, { endpoint: { ws: '-r--' } })).toBe(true);
expect(grants(reader, { endpoint: { ws: '--u-' } })).toBe(false);
expect(grants(reader, { endpoint: { ydoc: '--u-' } })).toBe(false);
});
it('lets an editor write over the socket and PATCH over http', () => {
expect(grants(editor, { ydoc: '--u-' })).toBe(true);
expect(grants(editor, { endpoint: { ws: '--u-' } })).toBe(true);
expect(grants(editor, { endpoint: { ydoc: '--u-' } })).toBe(true);
});
it('never lets a browser DELETE the document', () => {
expect(grants(editor, { endpoint: { ydoc: '---d' } })).toBe(false);
expect(grants(editor, { delete: ['soft'] })).toBe(false);
});
it('withholds create — populating initial content is the admin token', () => {
expect(grants(editor, { ydoc: 'c---' })).toBe(false);
});
});
describe('the history a user may read', () => {
it('starts the ray where the user got access and no earlier', () => {
for (const who of [reader, editor]) {
expect(grants(who, { history: { from: ACCESS_SINCE } })).toBe(true);
expect(grants(who, { history: { from: ACCESS_SINCE - 1 } })).toBe(false);
expect(grants(who, { history: { from: 0 } })).toBe(false);
}
});
it('opens the timeline read-only for reader and editor alike', () => {
for (const who of [reader, editor]) {
for (const name of ['activity', 'changeset']) {
expect(grants(who, { endpoint: { [name]: '-r--' } })).toBe(true);
expect(grants(who, { endpoint: { [name]: '--u-' } })).toBe(false);
}
}
});
it('never grants the full ray that would unlock gc=false', () => {
for (const who of [reader, editor]) {
expect(who.history).not.toBe(false);
expect(who.history.from).toBeGreaterThan(0);
}
});
it('never grants prune', () => {
for (const who of [reader, editor]) {
expect(grants(who, { history: { from: ACCESS_SINCE, prune: true } })).toBe(
false,
);
expect(grants(who, { endpoint: { prune: 'c---' } })).toBe(false);
}
});
});
describe('rollback', () => {
it('lets an editor undo a window inside its own ray', () => {
expect(
grants(editor, { history: { from: ACCESS_SINCE, rollback: true } }),
).toBe(true);
expect(grants(editor, { endpoint: { rollback: 'c---' } })).toBe(true);
});
it('refuses an editor a window wider than its ray, or unbounded', () => {
expect(grants(editor, { history: { from: 0, rollback: true } })).toBe(false);
expect(
grants(editor, { history: { from: ACCESS_SINCE - 1, rollback: true } }),
).toBe(false);
});
it('never lets a reader roll back anything', () => {
expect(
grants(reader, { history: { from: ACCESS_SINCE, rollback: true } }),
).toBe(false);
expect(grants(reader, { endpoint: { rollback: 'c---' } })).toBe(false);
});
it('is a dead grant without the write it rides on', () => {
const readerWithRollback = normalizePermissions({
...browserDocumentPermissions(false, ACCESS_SINCE),
history: { from: ACCESS_SINCE, rollback: true },
});
expect(readerWithRollback.history.rollback).toBe(false);
});
it('is withheld from the admin token', () => {
expect(grants(admin, { history: { from: 0, rollback: true } })).toBe(false);
});
});
describe('a reader who holds only the link', () => {
it('gets no history and cannot reach the timeline', () => {
for (const who of [linkReader, linkEditor]) {
expect(who.history).toBe(false);
expect(grants(who, { history: { from: ACCESS_SINCE } })).toBe(false);
for (const name of ['activity', 'changeset']) {
expect(grants(who, { endpoint: { [name]: '-r--' } })).toBe(false);
}
}
});
it('still reads and syncs the document like anyone else', () => {
expect(grants(linkReader, { ydoc: '-r--' })).toBe(true);
expect(grants(linkEditor, { ydoc: '--u-' })).toBe(true);
expect(grants(linkEditor, { endpoint: { ws: '--u-' } })).toBe(true);
});
});
describe('everything the browser must not reach', () => {
it.each([
'prune',
'create-ydoc',
'migrate',
'reset-connections',
'restore-ydoc',
'reset-ydoc',
'some-endpoint-added-later',
])('refuses %s to an editor — there is no "*" fallback', (name) => {
expect(grants(editor, { endpoint: { [name]: '-r--' } })).toBe(false);
expect(grants(editor, { endpoint: { [name]: 'c---' } })).toBe(false);
});
});
describe('the admin token', () => {
it('reaches every endpoint, named or not', () => {
expect(
grants(admin, { endpoint: { 'create-ydoc': 'crud', migrate: 'crud' } }),
).toBe(true);
});
it('reads and writes the document with its full history', () => {
expect(grants(admin, { ydoc: '-ru-' })).toBe(true);
expect(grants(admin, { history: { from: 0 } })).toBe(true);
});
it('soft-deletes but never hard-deletes over REST, and is not granted prune', () => {
expect(grants(admin, { delete: ['soft'] })).toBe(true);
expect(grants(admin, { delete: ['hard'] })).toBe(false);
expect(grants(admin, { history: { from: 0, prune: true } })).toBe(false);
});
});
describe('the public global routes', () => {
const globalPerms = normalizePermissions(publicGlobalPermissions);
const globalGrants = (required) =>
hasPermissions(globalPerms, { type: 'permissions:global:v1', ...required });
it.each(['ping', 'ready', 'jwks'])('serves %s to anyone, read only', (name) => {
expect(globalGrants({ endpoint: { [name]: '-r--' } })).toBe(true);
expect(globalGrants({ endpoint: { [name]: '--u-' } })).toBe(false);
});
it('refuses a global endpoint it does not name', () => {
expect(globalGrants({ endpoint: { anything: '-r--' } })).toBe(false);
});
});
describe('resolveHistoryFrom', () => {
const httpError = (status) =>
Object.assign(new Error(`HTTP ${status}`), { status });
const never = () => {
throw new Error('the access must not be fetched');
};
it('does not fetch an access the caller has no history to bound', async () => {
expect(await resolveHistoryFrom({ versions_list: false }, never)).toBe(null);
expect(await resolveHistoryFrom({}, never)).toBe(null);
expect(await resolveHistoryFrom(undefined, never)).toBe(null);
});
it('turns the access date into unix milliseconds', async () => {
const from = await resolveHistoryFrom({ versions_list: true }, async () => ({
created_at: '2023-11-14T22:13:20Z',
}));
expect(from).toBe(Date.parse('2023-11-14T22:13:20Z'));
});
it('grants no history when the backend refuses the access', async () => {
for (const status of [401, 403, 404]) {
expect(
await resolveHistoryFrom({ versions_list: true }, async () => {
throw httpError(status);
}),
).toBe(null);
}
});
it('rethrows when the backend does not answer at all', async () => {
for (const status of [500, 502, 503]) {
await expect(
resolveHistoryFrom({ versions_list: true }, async () => {
throw httpError(status);
}),
).rejects.toMatchObject({ status });
}
await expect(
resolveHistoryFrom({ versions_list: true }, async () => {
throw new TypeError('fetch failed');
}),
).rejects.toBeInstanceOf(TypeError);
});
it('grants no history rather than all of it on an unusable date', async () => {
for (const created_at of [undefined, null, '', 'not a date']) {
expect(
await resolveHistoryFrom({ versions_list: true }, async () => ({
created_at,
})),
).toBe(null);
}
expect(
await resolveHistoryFrom({ versions_list: true }, async () => undefined),
).toBe(null);
});
it('refuses the epoch, which would unlock a gc=false socket', async () => {
expect(
await resolveHistoryFrom({ versions_list: true }, async () => ({
created_at: '1970-01-01T00:00:00Z',
})),
).toBe(null);
});
});
@@ -1,24 +1,22 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
// Reached by path rather than by name on purpose. `@y/hub`'s export map exposes
// only the package index, which pulls in uws, redis and postgres — and the two
// functions this test needs to run yhub's real pipeline (normalize the plugin's
// answer, then ask it the gate's question) are not on it. Importing the module
// directly keeps the test to plain objects, and if yhub ever moves the file the
// failure is loud rather than silently vacuous.
// `@y/hub/permissions` is a subpath export of its own (since 0.9.0): the two
// functions this test needs to run yhub's real pipeline — normalize the
// plugin's answer, then ask it the gate's question — without the package index
// pulling in uws, redis and postgres.
import {
createDocumentPermissions,
hasPermissions,
normalizePermissions,
} from './node_modules/@y/hub/src/permissions.js';
} from '@y/hub/permissions';
import {
adminDocumentPermissions,
browserDocumentPermissions,
publicGlobalPermissions,
resolveHistoryFrom,
} from './permissions.js';
} from '../permissions.js';
/**
* These tables are Docs' entire access policy, and yhub reads them literally
+242
View File
@@ -0,0 +1,242 @@
// server.js — configuration, the auth plugin, the custom REST endpoints.
//
// The module has no exports and ends on `await createYHub(...)`, so what a unit
// test can hold onto is its boot contract: the environment it refuses, and the
// configuration it hands yhub when it accepts. `@y/hub`, its S3 plugin and
// `./migration.js` are faked; the config object passed to `createYHub` and the
// args passed to `S3PersistenceV1` are captured.
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { createYHub, s3PluginArgs } = vi.hoisted(() => ({
createYHub: vi.fn(async (config) => {
createYHub.lastConfig = config;
return { stream: { taskDebounce: 0, minMessageLifetime: 0 } };
}),
s3PluginArgs: [],
}));
vi.mock('@y/hub', () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
child: () => logger,
};
return {
createYHub,
logger,
apiError: (status, message) => Object.assign(new Error(message), { status }),
checkPermissions: vi.fn(),
createApiEndpoint: (name, opts) => ({ name, opts }),
createAuthPlugin: (plugin) => plugin,
createAuthorize: (handlers) => handlers,
createDocumentPermissions: (x) => x,
};
});
vi.mock('@y/hub/plugins/s3', () => ({
S3PersistenceV1: class {
constructor(args) {
this.args = args;
s3PluginArgs.push(args);
}
},
}));
vi.mock('../migration.js', () => ({
SOFT_MIGRATION: false,
fullMigrate: vi.fn(),
isPermanentFailure: vi.fn(() => false),
maybeMigrate: vi.fn(),
migrationLog: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
// A clean, minimal environment for every test: only what a test names is
// stubbed on top of this.
const BASE_ENV = {
REDIS: 'redis://localhost:6379',
POSTGRES: 'postgres://localhost:5432/yhub',
COLLABORATION_SERVER_ORIGIN: 'http://localhost:3000',
SOFT_MIGRATION: 'false',
};
const boot = () => import('../server.js');
beforeEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
createYHub.mockClear();
createYHub.lastConfig = undefined;
s3PluginArgs.length = 0;
for (const [key, value] of Object.entries(BASE_ENV)) vi.stubEnv(key, value);
// vars a previous test may have set that must not leak in as defaults
for (const key of [
'YHUB_ROLE',
'YHUB_TASK_CONCURRENCY',
'YHUB_TASK_DEBOUNCE_MS',
'YHUB_MIN_MESSAGE_LIFETIME_MS',
'YHUB_S3_PERSISTENCE',
'YHUB_S3_ENDPOINT_URL',
'YHUB_S3_ACCESS_KEY_ID',
'YHUB_S3_SECRET_ACCESS_KEY',
'YHUB_S3_BUCKET_NAME',
'YHUB_S3_REGION_NAME',
'PORT',
]) {
vi.stubEnv(key, '');
}
});
describe('a valid, minimal environment', () => {
it('starts one yhub with both halves and the collaboration prefix', async () => {
await boot();
expect(createYHub).toHaveBeenCalledTimes(1);
const config = createYHub.lastConfig;
expect(config.redis.prefix).toBe('yhub');
expect(config.postgres).toBe('postgres://localhost:5432/yhub');
expect(config.server.apiPrefix).toBe('collaboration');
expect(config.server.port).toBe(3002);
expect(config.server.cors).toEqual({
origin: ['http://localhost:3000'],
credentials: true,
});
expect(config.worker).not.toBeNull();
expect(config.persistence).toEqual([]);
});
it('passes the stream tuning through with Docs defaults', async () => {
await boot();
const { redis } = createYHub.lastConfig;
expect(redis.taskDebounce).toBe(10000);
expect(redis.minMessageLifetime).toBe(60000);
});
it('splits the origin allowlist on commas', async () => {
vi.stubEnv(
'COLLABORATION_SERVER_ORIGIN',
'https://a.example,https://b.example',
);
await boot();
expect(createYHub.lastConfig.server.cors.origin).toEqual([
'https://a.example',
'https://b.example',
]);
});
it('honours PORT', async () => {
vi.stubEnv('PORT', '4000');
await boot();
expect(createYHub.lastConfig.server.port).toBe(4000);
});
});
describe('YHUB_ROLE', () => {
it('refuses an unknown role at boot', async () => {
vi.stubEnv('YHUB_ROLE', 'bogus');
await expect(boot()).rejects.toThrow(/YHUB_ROLE must be one of/);
});
it('a server role binds the port and claims no task', async () => {
vi.stubEnv('YHUB_ROLE', 'server');
await boot();
expect(createYHub.lastConfig.server).not.toBeNull();
expect(createYHub.lastConfig.worker).toBeNull();
});
it('a worker role claims tasks and binds no port', async () => {
vi.stubEnv('YHUB_ROLE', 'worker');
await boot();
expect(createYHub.lastConfig.server).toBeNull();
expect(createYHub.lastConfig.worker).not.toBeNull();
});
});
describe('the numeric tuning knobs', () => {
it('refuses a non-integer concurrency', async () => {
vi.stubEnv('YHUB_TASK_CONCURRENCY', 'abc');
await expect(boot()).rejects.toThrow(
/YHUB_TASK_CONCURRENCY must be an integer >= 1/,
);
});
it('refuses a concurrency below one', async () => {
vi.stubEnv('YHUB_TASK_CONCURRENCY', '0');
await expect(boot()).rejects.toThrow(/YHUB_TASK_CONCURRENCY/);
});
it('accepts a debounce of zero but not of minus one', async () => {
vi.stubEnv('YHUB_TASK_DEBOUNCE_MS', '0');
await expect(boot()).resolves.toBeDefined();
vi.resetModules();
vi.stubEnv('YHUB_TASK_DEBOUNCE_MS', '-1');
await expect(boot()).rejects.toThrow(/YHUB_TASK_DEBOUNCE_MS/);
});
it('treats a blank variable as the default', async () => {
vi.stubEnv('YHUB_TASK_CONCURRENCY', '');
await boot();
expect(createYHub.lastConfig.worker.taskConcurrency).toBe(5);
});
});
describe('the S3 persistence plugin', () => {
const S3_ENV = {
YHUB_S3_ENDPOINT_URL: 'https://s3.example.test',
YHUB_S3_ACCESS_KEY_ID: 'key',
YHUB_S3_SECRET_ACCESS_KEY: 'secret',
YHUB_S3_BUCKET_NAME: 'yhub-blobs',
};
const stubS3Env = () => {
for (const [k, v] of Object.entries(S3_ENV)) vi.stubEnv(k, v);
};
it('loads no plugin when no bucket is configured', async () => {
await boot();
expect(s3PluginArgs).toHaveLength(0);
});
it('refuses a half-configured bucket, naming what is missing', async () => {
vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'https://s3.example.test');
await expect(boot()).rejects.toThrow(
/partly configured, missing YHUB_S3_ACCESS_KEY_ID/,
);
});
it('refuses YHUB_S3_PERSISTENCE=true without the bucket settings', async () => {
vi.stubEnv('YHUB_S3_PERSISTENCE', 'true');
await expect(boot()).rejects.toThrow(/YHUB_S3_PERSISTENCE=true requires/);
});
it('attaches the plugin read-only when the toggle is off', async () => {
stubS3Env();
await boot();
expect(s3PluginArgs).toHaveLength(1);
expect(s3PluginArgs[0]).toMatchObject({
bucket: 'yhub-blobs',
branches: [],
deleteVersions: true,
});
});
it('offloads every branch when the toggle is on', async () => {
stubS3Env();
vi.stubEnv('YHUB_S3_PERSISTENCE', 'true');
await boot();
expect(s3PluginArgs[0].branches).toBe(true);
});
it('refuses an endpoint URL that carries a path', async () => {
stubS3Env();
vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'https://s3.example.test/bucket');
await expect(boot()).rejects.toThrow(/must not contain a path/);
});
it('refuses a non-http(s) endpoint scheme', async () => {
stubS3Env();
vi.stubEnv('YHUB_S3_ENDPOINT_URL', 'ftp://s3.example.test');
await expect(boot()).rejects.toThrow(/must be http:\/\/ or https:\/\//);
});
});
+1493 -1
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -6,7 +6,8 @@
"start": "node server.js",
"dev": "nodemon --delay 1 server.js",
"init-db": "node node_modules/@y/hub/bin/init-db.js",
"test": "node --test"
"test": "node --test __tests__/permissions.test.js && vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@aws-sdk/client-s3": "3.1110.0",
@@ -15,7 +16,8 @@
"jose": "6.2.8"
},
"devDependencies": {
"nodemon": "3.1.14"
"nodemon": "3.1.14",
"vitest": "3.2.7"
},
"engines": {
"node": ">=22"
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config';
// The unit suite. `permissions.test.js` stays on node's own test runner
// (`npm test` runs it first) — it reaches into `@y/hub`'s internals by path and
// is deliberately kept to plain `node:test`. Everything vitest runs lives in
// `__tests__/*.spec.mjs`, so the two runners never fight over the same file.
export default defineConfig({
test: {
include: ['__tests__/**/*.spec.mjs'],
// migration.js reads SOFT_MIGRATION and the LEGACY_S3_* variables at import
// time and builds (or refuses to build) its S3 client from them. The specs
// that need a live client set the variables themselves before importing the
// module; this is only the floor, so an unset environment cannot make the
// module throw on load in the specs that mock it instead.
env: {
SOFT_MIGRATION: 'false',
},
// each spec file resets modules and re-imports with its own environment
isolate: true,
clearMocks: true,
},
});