Security (must-fix):
- /git smart-HTTP gate: an authenticated NON-member of a git-sync space now gets
404 (not 403), so the 403<->404 difference can no longer be used to brute-force
which spaces exist / have git-sync enabled. 403 is reserved for a MEMBER who
lacks the required role (existence already known). New gate input
userIsSpaceMember; decision-table + service specs extended.
Config (must-fix):
- Remove the dead GIT_SYNC_SSH_KEY_PATH knob (getter + validation field + two
.env.example lines) — it had zero consumers and advertised a nonexistent push
capability.
Stability/docs (warnings):
- Wire the lost-lock AbortSignal into runReceivePack -> git http-backend so the
receive-pack child is killed if the per-space lock lapses mid-write.
- Raise the divergent-`docmost` (invariant §5) push refusal from info -> warn and
surface divergentDocmost in the run status (/status).
- Comment the stale read-after-debounced-collab-write updatedAt in
importPageMarkdown (deferred §10 loop-guard must not trust it).
- Fix the Dockerfile comment: the loader uses require.resolve + dynamic import(),
it deliberately does NOT require('@docmost/git-sync').
- Merge the two near-identical space toggle handlers into one parameterized
handler; add the 2 missing en-US i18n keys for the auto-merge switch (ru-RU not
maintained for these git-sync strings, mirrored).
Tests:
- isGitSyncHttpEnabled() default-branch (unset -> isGitSyncEnabled fallback).
- agentSourceFields 'git-sync' case (source stamped, chat key omitted).
- editor-ext name-level schema contract (vendored mirror superset of editor-ext
node/mark types) + the new shared resolver + non-member 404 gate cases.
Architecture:
- Extract resolveRequestWorkspace shared by DomainMiddleware + GitHttpService
(the two real self-hosted/cloud copies; McpService has no cloud branch).
- Document the in-process setInterval multi-replica limitation + BullMQ/fencing
future direction (deferred, not implemented).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
6.0 KiB
TypeScript
176 lines
6.0 KiB
TypeScript
import { EnvironmentService } from './environment.service';
|
|
|
|
// Direct instantiation with a stub ConfigService, mirroring the rest of these
|
|
// unit specs.
|
|
describe('EnvironmentService', () => {
|
|
let service: EnvironmentService;
|
|
|
|
beforeEach(() => {
|
|
service = new EnvironmentService(
|
|
{} as any, // configService
|
|
);
|
|
});
|
|
|
|
it('should be defined', () => {
|
|
expect(service).toBeDefined();
|
|
});
|
|
|
|
describe('getGitSyncPollIntervalMs', () => {
|
|
const withEnv = (value?: string) =>
|
|
new EnvironmentService({
|
|
get: (_key: string, fallback?: string) => value ?? fallback,
|
|
} as any);
|
|
|
|
it('defaults to 15000 when unset', () => {
|
|
expect(withEnv().getGitSyncPollIntervalMs()).toBe(15000);
|
|
});
|
|
|
|
it('parses a valid positive int', () => {
|
|
expect(withEnv('30000').getGitSyncPollIntervalMs()).toBe(30000);
|
|
});
|
|
|
|
it('falls back to 15000 for non-positive or unparseable values', () => {
|
|
expect(withEnv('0').getGitSyncPollIntervalMs()).toBe(15000);
|
|
expect(withEnv('-100').getGitSyncPollIntervalMs()).toBe(15000);
|
|
expect(withEnv('not-a-number').getGitSyncPollIntervalMs()).toBe(15000);
|
|
});
|
|
});
|
|
|
|
describe('getGitSyncDebounceMs', () => {
|
|
const withEnv = (value?: string) =>
|
|
new EnvironmentService({
|
|
get: (_key: string, fallback?: string) => value ?? fallback,
|
|
} as any);
|
|
|
|
it('defaults to 2000 when unset', () => {
|
|
expect(withEnv().getGitSyncDebounceMs()).toBe(2000);
|
|
});
|
|
|
|
it('parses a valid positive int', () => {
|
|
expect(withEnv('500').getGitSyncDebounceMs()).toBe(500);
|
|
});
|
|
|
|
it('falls back to 2000 for non-positive or unparseable values', () => {
|
|
expect(withEnv('0').getGitSyncDebounceMs()).toBe(2000);
|
|
expect(withEnv('-5').getGitSyncDebounceMs()).toBe(2000);
|
|
expect(withEnv('not-a-number').getGitSyncDebounceMs()).toBe(2000);
|
|
});
|
|
});
|
|
|
|
// getGitSyncDataDir reads two distinct keys (GIT_SYNC_DATA_DIR and DATA_DIR),
|
|
// so this builder maps each key to a supplied value (and honours the fallback
|
|
// the getter passes for DATA_DIR's `|| './data'`).
|
|
describe('getGitSyncDataDir', () => {
|
|
const withEnv = (values: Record<string, string | undefined>) =>
|
|
new EnvironmentService({
|
|
get: (key: string, fallback?: string) => values[key] ?? fallback,
|
|
} as any);
|
|
|
|
it("defaults to './data/git-sync' when neither key is set", () => {
|
|
expect(withEnv({}).getGitSyncDataDir()).toBe('./data/git-sync');
|
|
});
|
|
|
|
it('derives from DATA_DIR with the /git-sync suffix', () => {
|
|
expect(
|
|
withEnv({ DATA_DIR: '/var/lib/docmost' }).getGitSyncDataDir(),
|
|
).toBe('/var/lib/docmost/git-sync');
|
|
});
|
|
|
|
it('strips trailing slashes from DATA_DIR before appending', () => {
|
|
expect(
|
|
withEnv({ DATA_DIR: '/var/lib/docmost///' }).getGitSyncDataDir(),
|
|
).toBe('/var/lib/docmost/git-sync');
|
|
});
|
|
|
|
it('lets an explicit GIT_SYNC_DATA_DIR override the DATA_DIR derivation', () => {
|
|
expect(
|
|
withEnv({
|
|
GIT_SYNC_DATA_DIR: '/custom/vault',
|
|
DATA_DIR: '/var/lib/docmost',
|
|
}).getGitSyncDataDir(),
|
|
).toBe('/custom/vault');
|
|
});
|
|
|
|
it('returns the explicit override verbatim (no /git-sync suffix, no slash strip)', () => {
|
|
expect(
|
|
withEnv({ GIT_SYNC_DATA_DIR: '/custom/vault/' }).getGitSyncDataDir(),
|
|
).toBe('/custom/vault/');
|
|
});
|
|
});
|
|
|
|
// isGitSyncEnabled is the `.toLowerCase() === 'true'` contract: only a
|
|
// case-insensitive "true" enables it; everything else (unset, "false",
|
|
// garbage) is false.
|
|
describe('isGitSyncEnabled', () => {
|
|
const withEnv = (value?: string) =>
|
|
new EnvironmentService({
|
|
get: (_key: string, fallback?: string) => value ?? fallback,
|
|
} as any);
|
|
|
|
it('is true for "true" and "TRUE" (case-insensitive)', () => {
|
|
expect(withEnv('true').isGitSyncEnabled()).toBe(true);
|
|
expect(withEnv('TRUE').isGitSyncEnabled()).toBe(true);
|
|
});
|
|
|
|
it('is false when unset (defaults to "false")', () => {
|
|
expect(withEnv().isGitSyncEnabled()).toBe(false);
|
|
});
|
|
|
|
it('is false for "false" and garbage values', () => {
|
|
expect(withEnv('false').isGitSyncEnabled()).toBe(false);
|
|
expect(withEnv('maybe').isGitSyncEnabled()).toBe(false);
|
|
expect(withEnv('1').isGitSyncEnabled()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// isGitSyncHttpEnabled is the master gate of the /git smart-HTTP trust boundary.
|
|
// When GIT_SYNC_HTTP_ENABLED is UNSET it FALLS BACK to isGitSyncEnabled(); when
|
|
// set it is honored verbatim ('true' -> on, anything else -> off). The fallback
|
|
// (default) branch is what these tests pin.
|
|
describe('isGitSyncHttpEnabled', () => {
|
|
const withEnv = (values: Record<string, string | undefined>) =>
|
|
new EnvironmentService({
|
|
get: (key: string, fallback?: string) => values[key] ?? fallback,
|
|
} as any);
|
|
|
|
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === true', () => {
|
|
expect(
|
|
withEnv({ GIT_SYNC_ENABLED: 'true' }).isGitSyncHttpEnabled(),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('DEFAULT branch: unset -> falls back to isGitSyncEnabled() === false', () => {
|
|
// Neither key set: the fallback resolves to isGitSyncEnabled() which is
|
|
// false by default.
|
|
expect(withEnv({}).isGitSyncHttpEnabled()).toBe(false);
|
|
expect(
|
|
withEnv({ GIT_SYNC_ENABLED: 'false' }).isGitSyncHttpEnabled(),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('explicit "true" enables the host regardless of GIT_SYNC_ENABLED', () => {
|
|
expect(
|
|
withEnv({
|
|
GIT_SYNC_HTTP_ENABLED: 'true',
|
|
GIT_SYNC_ENABLED: 'false',
|
|
}).isGitSyncHttpEnabled(),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('explicit non-"true" disables the host even when sync is enabled', () => {
|
|
expect(
|
|
withEnv({
|
|
GIT_SYNC_HTTP_ENABLED: 'false',
|
|
GIT_SYNC_ENABLED: 'true',
|
|
}).isGitSyncHttpEnabled(),
|
|
).toBe(false);
|
|
expect(
|
|
withEnv({
|
|
GIT_SYNC_HTTP_ENABLED: 'maybe',
|
|
GIT_SYNC_ENABLED: 'true',
|
|
}).isGitSyncHttpEnabled(),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
});
|