mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-22 17:45:12 +02:00
✅(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:
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
@@ -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:\/\//);
|
||||
});
|
||||
});
|
||||
Generated
+1493
-1
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user