mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 19:01:39 +00:00
* fix(mcp): close HTTP MCP shell-job RCE + tighten remote contract
The HTTP MCP transport in serve-http.ts inlined its own OperationContext
literal and forgot to set `remote: true`. With the field undefined at the
operations.ts protected-job-name guard (line 1391), an HTTP MCP caller
holding a write-scoped OAuth token could submit `submit_job {name: "shell"}`
and execute arbitrary commands on the gbrain host (RCE-class).
Two-layer fix:
1. F7 — explicit `remote: true` on the inlined /mcp OperationContext.
Stdio MCP at src/mcp/dispatch.ts:61 already set this; the HTTP path
was the regression.
2. F7b — fail-closed contract on the four ctx.remote consumer sites in
operations.ts (auto-link skip, telemetry x2, protected-job guard).
The protected-job guard flips from `if (ctx.remote && ...)` to
`if (ctx.remote !== false && ...)` and the trusted-marker site flips
from `!ctx.remote && ...` to `ctx.remote === false && ...`. Anything
that isn't strictly `false` now treats the caller as remote/untrusted.
3. D12 — `OperationContext.remote` becomes REQUIRED in the TypeScript
type. The compiler now catches future transports that forget the field.
The runtime fail-closed defaults are belt+suspenders for any caller
that bypasses the type via `as` cast or `Partial<>` spread.
Tests:
- New `test/trust-boundary-contract.test.ts` (4 cases) pins the
fail-closed semantics: undefined-via-cast rejects, remote=true rejects,
remote=false allowed (only path that escalates protected-name jobs).
- `test/e2e/serve-http-oauth.test.ts` adds 2 cases asserting HTTP MCP
cannot submit `shell` or `subagent` jobs even with read+write scope.
- `test/e2e/graph-quality.test.ts` adds the now-required `remote: false`
to its fixture (e2e graph quality simulates local-CLI writes).
Verification: bun test -> 3742 pass / 0 fail. typecheck clean.
Thanks to @ElectricSheepIO on X for the security review that surfaced
this trust-boundary regression.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oauth): RFC 6749 hardening + serve-http defense in depth
OAuth provider hardening pass that brings the provider into RFC compliance
on auth code, refresh token, and revocation flows, and tightens the
serve-http surface around request logging and admin cookies.
Provider (src/core/oauth-provider.ts):
- F1: bind client_id atomically into the auth code DELETE WHERE clause for
exchangeAuthorizationCode + challengeForAuthorizationCode. Previous
pattern (DELETE...RETURNING then post-hoc client compare) burned codes
on the wrong-client path so the legitimate client could not retry.
RFC 6749 §10.5.
- F2: same atomic predicate on exchangeRefreshToken. The pre-fix shape
defeated RFC 6749 §10.4's stolen-token detection by letting attacker +
victim both succeed.
- F3: refresh token rejects requested scopes that are not a subset of the
ORIGINAL grant on the row. Codex C9: subset is checked against the
recorded grant, not the client's currently-allowed scopes (which can
expand later); omitted scope inherits the original verbatim and stays
distinct from explicit-empty. RFC 6749 §6.
- F4: revokeToken adds AND client_id to the DELETE so a client cannot
revoke another client's tokens by guessing the hash. RFC 7009 §2.1.
- F5: deleted_at and token_ttl column probes use a new
isUndefinedColumnError helper (extracted to src/core/utils.ts per D14)
that matches SQLSTATE 42703 or column-name-in-message. Bare catch{}
used to swallow lock timeouts, network blips, and auth failures as
"column missing" — fail-open posture in a security path.
- F6: sweepExpiredTokens uses RETURNING 1 + array length. Pre-fix
(result as any).count returned 0 on at least one engine even when
rows were deleted, and codes were never counted.
- F7c: NEW finding eva-brain missed. exchangeAuthorizationCode now folds
redirect_uri into the atomic DELETE predicate when the parameter is
provided. Stored on /authorize, never compared on /token before this
commit. RFC 6749 §4.1.3 violation. Back-compat: when caller omits the
parameter the predicate is skipped, preserving SDK consumers that
haven't adopted the parameter yet.
- F12 (cleanup, not security): dcrDisabled constructor option replaces
the prior monkey-patch of _clientsStore in serve-http.ts. The SDK's
mcpAuthRouter only wires up /register when the store exposes
registerClient, so omitting the method via the constructor is
sufficient. Reframed as cleanup per codex C10 — the monkey-patch
happened before mcpAuthRouter ran, so the prior shape did not have
a real security regression to claim.
Dispatch (src/mcp/dispatch.ts):
- F8: new summarizeMcpParams(opName, params) intersects submitted keys
against the operation's declared params allow-list. Returns
{redacted, kind, declared_keys, unknown_key_count, approx_bytes}.
Closes the codex C8 leak: a naive "dump all submitted keys" summary
still echoed attacker-controlled key names like
put_page {"wiki/people/sensitive_name": "..."} into mcp_request_log
+ the SSE feed. Allow-list pattern keeps debug visibility on declared
keys while counting unknowns without naming them.
Serve-http (src/commands/serve-http.ts) + serve (src/commands/serve.ts):
- F8 wiring: mcp_request_log + SSE broadcast routed through
summarizeMcpParams by default. New --log-full-params flag bypasses
redaction with a loud stderr warning at startup. Default privacy-
positive; flag is the documented escape hatch for self-hosted
operators debugging on their own laptop.
- F9: admin cookies set Secure when req.secure OR issuerUrl.protocol
is https. Cloudflare-tunnel + reverse-proxy deployments where the
inside-tunnel hop looks like http but the public URL is https now
tag cookies correctly.
- F10: bound magicLinkNonces with NONCE_LRU_CAP. Previously only the
consumed-nonces map was capped; an attacker (or misbehaving agent)
with the bootstrap token could mint nonces faster than they expired
and grow the live store unbounded.
- F12: dcrDisabled flows through to the provider constructor instead of
monkey-patching _clientsStore after construction.
- F14: try/catch wraps StreamableHTTPServerTransport setup +
handleRequest. SDK-level throws no longer fall through to express's
default HTML error page; clients expecting JSON-RPC envelopes get a
JSON 500 instead.
- F15: error envelope unified via buildError + serializeError from
src/core/errors.ts. OperationError and unexpected exceptions both
emit the same {class, code, message, hint} shape so clients can
pattern-match a single envelope.
Tests:
- test/oauth.test.ts adds 11 cases:
* F1+F2 wrong-client cannot consume / read PKCE / burn refresh,
paired with owner-still-redeems atomically afterward (codex D6 —
proves the predicate doesn't burn the row on attacker attempts).
* F3 refresh scope subset enforced.
* F4 wrong-client cannot revoke.
* F5 non-schema SQL not swallowed by client_credentials soft-delete probe.
* F6 sweepExpiredTokens returns count > 0 after deleting rows.
* F7c redirect_uri match succeeds, mismatch rejects, omitted preserves
back-compat for callers that don't pass the parameter.
* F12 dcrDisabled constructor option exposes only getClient,
registerClientManual still works.
- test/mcp-dispatch-summarize.test.ts (NEW, 6 cases): pins the F8
privacy invariants. The codex-C8 attacker-key-name probe asserts that
a sensitive name submitted as a key never appears anywhere in the
redactor's output.
Verification: bun run typecheck clean. test/oauth.test.ts 55/55,
test/mcp-dispatch-summarize.test.ts 6/6,
test/trust-boundary-contract.test.ts 4/4 from commit A. The one
unrelated unit failure surfaces on master too — environment-sensitive
test that expects ~/.gbrain/config.json to be absent in the test env.
Out of scope: F11 (auth register-client --redirect-uri flag) and F13
(serve --http argv positive-int validator) per codex C11 — operator
UX gaps, not trust-boundary fixes. Filed as follow-up TODOs.
Thanks to @ElectricSheepIO on X for the security review that surfaced
this hardening pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: file F11 + F13 as OAuth hardening follow-up TODOs
Codex C11 flagged these as scope creep on the v0.26.7 OAuth hardening
PR (operator UX, not trust-boundary). Capturing them here so the
context survives — eva-brain has both implementations and the lift is
mechanical when we want to do them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(oauth): close adversarial-review findings on F7c + F8
Two bugs surfaced by an adversarial subagent during /ship's pre-landing
review pass that the codex + plan-eng-review didn't catch.
D15 / F7c: `exchangeAuthorizationCode` used `redirectUri ? ...` ternary
to choose the with-redirect vs no-redirect SQL. Empty string fell
through to the no-redirect branch, so a caller submitting
`redirect_uri=""` at /token bypassed the binding entirely. RFC 6749
§4.1.3 spec violation. Switch to `redirectUri !== undefined`. Test:
empty-string redirect_uri must reject when /authorize stored a real URI.
D16 / F8: `summarizeMcpParams` published exact byte length via
`approx_bytes = JSON.stringify(params).length`. Submitting put_page with
a known prefix and observing the resulting log entry across repeated
probes lets an attacker binary-search the size of secret suffix content.
Bucket to 1KB resolution. The redacted summary keeps a coarse
"roughly how big" signal for operators while making size-based
side-channel attacks useless.
Test count: 65 → 67 across the three new test files.
Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: bump version and changelog (v0.26.9)
OAuth 2.1 hardening + HTTP MCP shell-job RCE fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: update project documentation for v0.26.9
Annotate CLAUDE.md key-files entries with v0.26.9 OAuth/MCP hardening pass:
- src/core/operations.ts: D12 (OperationContext.remote required) + F7b
(4-site fail-closed flip), HTTP MCP shell-job RCE close
- src/core/utils.ts: D14 isUndefinedColumnError extracted helper
- src/mcp/dispatch.ts: F8 summarizeMcpParams privacy redactor with
declared-keys allow-list + 1KB byte bucketing
- src/commands/serve-http.ts: F7+F8+F9+F10+F12+F14+F15 hardening
- src/core/oauth-provider.ts: F1+F2+F3+F4+F5+F6+F7c+F12 RFC 6749/7009
hardening pass
Add new test-file entries for test/mcp-dispatch-summarize.test.ts
(7 cases) and test/trust-boundary-contract.test.ts (4 cases). Extend
test/oauth.test.ts (+14 cases) and test/e2e/serve-http-oauth.test.ts
(+2 RCE-close regressions) entries with v0.26.9 case counts.
README.md: added --log-full-params to gbrain serve --http surface.
SECURITY.md: documented mcp_request_log.params redaction default
({redacted, kind, declared_keys, unknown_key_count, approx_bytes}) +
--log-full-params opt-in.
docs/mcp/DEPLOY.md: operator-facing note on SSE feed + audit log
redaction default and when to flip --log-full-params on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
|
* E2E test for the v0.10.1 knowledge graph layer.
|
|
*
|
|
* Runs the full pipeline against in-memory PGLite (no API keys, no external DB).
|
|
* 1. Seed pages with entity refs and timeline content
|
|
* 2. Run link-extract + timeline-extract
|
|
* 3. Verify graph populated
|
|
* 4. Test auto-link via put_page operation handler
|
|
* 5. Test reconciliation (edit page, stale links removed)
|
|
* 6. Test graph-query traversal
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
|
import { PGLiteEngine } from '../../src/core/pglite-engine.ts';
|
|
import { runExtract } from '../../src/commands/extract.ts';
|
|
import { operationsByName } from '../../src/core/operations.ts';
|
|
import type { OperationContext } from '../../src/core/operations.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
}, 60_000);
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
async function truncateAll() {
|
|
for (const t of ['content_chunks', 'links', 'tags', 'raw_data', 'timeline_entries', 'page_versions', 'ingest_log', 'pages']) {
|
|
await (engine as any).db.exec(`DELETE FROM ${t}`);
|
|
}
|
|
}
|
|
|
|
function makeContext(): OperationContext {
|
|
return {
|
|
engine,
|
|
config: { engine: 'pglite' } as any,
|
|
logger: { info: () => {}, warn: () => {}, error: () => {} },
|
|
dryRun: false,
|
|
// E2E graph quality simulates local-CLI writes (auto-link / timeline run).
|
|
// After F7b made `remote` required this needs to be explicit.
|
|
remote: false,
|
|
};
|
|
}
|
|
|
|
describe('E2E graph quality (v0.10.1 pipeline)', () => {
|
|
beforeEach(truncateAll, 15_000);
|
|
|
|
test('full pipeline: seed -> link-extract -> timeline-extract -> verify', async () => {
|
|
// Seed 5 pages with entity refs and timeline content.
|
|
await engine.putPage('people/alice', {
|
|
type: 'person', title: 'Alice',
|
|
compiled_truth: 'Alice is the CEO of [Acme](companies/acme).',
|
|
timeline: '- **2026-01-15** | Joined as CEO\n- **2026-02-20** | Closed Series A',
|
|
});
|
|
await engine.putPage('people/bob', {
|
|
type: 'person', title: 'Bob',
|
|
compiled_truth: 'Bob is a YC partner who invested in [Acme](companies/acme).',
|
|
timeline: '- **2026-03-01** | Wrote check to Acme',
|
|
});
|
|
await engine.putPage('companies/acme', {
|
|
type: 'company', title: 'Acme',
|
|
compiled_truth: '',
|
|
timeline: '- **2026-01-01** | Founded',
|
|
});
|
|
await engine.putPage('meetings/standup', {
|
|
type: 'meeting', title: 'Standup',
|
|
compiled_truth: 'Attendees: [Alice](people/alice), [Bob](people/bob).',
|
|
timeline: '- **2026-04-01** | Met at YC office',
|
|
});
|
|
|
|
// Run extractions.
|
|
await runExtract(engine, ['links', '--source', 'db']);
|
|
await runExtract(engine, ['timeline', '--source', 'db']);
|
|
|
|
// Verify graph populated.
|
|
const stats = await engine.getStats();
|
|
expect(stats.link_count).toBeGreaterThan(0);
|
|
expect(stats.timeline_entry_count).toBeGreaterThan(0);
|
|
|
|
// Verify typed link inference.
|
|
const aliceLinks = await engine.getLinks('people/alice');
|
|
const acmeLink = aliceLinks.find(l => l.to_slug === 'companies/acme');
|
|
expect(acmeLink?.link_type).toBe('works_at');
|
|
|
|
const bobLinks = await engine.getLinks('people/bob');
|
|
const bobAcme = bobLinks.find(l => l.to_slug === 'companies/acme');
|
|
expect(bobAcme?.link_type).toBe('invested_in');
|
|
|
|
const meetingLinks = await engine.getLinks('meetings/standup');
|
|
expect(meetingLinks.every(l => l.link_type === 'attended')).toBe(true);
|
|
});
|
|
|
|
test('auto-link via put_page operation handler', async () => {
|
|
// Seed target pages first.
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
|
await engine.putPage('companies/acme', { type: 'company', title: 'Acme', compiled_truth: '', timeline: '' });
|
|
|
|
// Use put_page operation (not engine.putPage directly) so the auto-link
|
|
// post-hook fires.
|
|
const putOp = operationsByName['put_page'];
|
|
expect(putOp).toBeDefined();
|
|
const result = await putOp.handler(makeContext(), {
|
|
slug: 'meetings/auto',
|
|
content: `---
|
|
type: meeting
|
|
title: Auto Meeting
|
|
---
|
|
|
|
Attendees: [Alice](people/alice). Discussed [Acme](companies/acme).
|
|
`,
|
|
});
|
|
|
|
// The response should include auto_links results.
|
|
expect((result as any).auto_links).toBeDefined();
|
|
const autoLinks = (result as any).auto_links;
|
|
expect(autoLinks.created).toBeGreaterThan(0);
|
|
expect(autoLinks.errors).toBe(0);
|
|
|
|
// Verify links actually exist in DB.
|
|
const links = await engine.getLinks('meetings/auto');
|
|
expect(links.length).toBe(2);
|
|
expect(new Set(links.map(l => l.to_slug))).toEqual(new Set(['people/alice', 'companies/acme']));
|
|
});
|
|
|
|
test('auto-link reconciliation: edit page removes stale links', async () => {
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
|
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: '', timeline: '' });
|
|
|
|
const putOp = operationsByName['put_page'];
|
|
|
|
// First write: links to Alice.
|
|
await putOp.handler(makeContext(), {
|
|
slug: 'notes/test',
|
|
content: `---
|
|
type: concept
|
|
title: Test Note
|
|
---
|
|
|
|
I met [Alice](people/alice) today.
|
|
`,
|
|
});
|
|
|
|
let links = await engine.getLinks('notes/test');
|
|
expect(links.length).toBe(1);
|
|
expect(links[0].to_slug).toBe('people/alice');
|
|
|
|
// Second write: removes Alice ref, adds Bob ref.
|
|
const result = await putOp.handler(makeContext(), {
|
|
slug: 'notes/test',
|
|
content: `---
|
|
type: concept
|
|
title: Test Note
|
|
---
|
|
|
|
Now I'm meeting with [Bob](people/bob).
|
|
`,
|
|
});
|
|
|
|
expect((result as any).auto_links.removed).toBe(1);
|
|
expect((result as any).auto_links.created).toBe(1);
|
|
|
|
links = await engine.getLinks('notes/test');
|
|
expect(links.length).toBe(1);
|
|
expect(links[0].to_slug).toBe('people/bob');
|
|
});
|
|
|
|
test('auto-timeline: put_page extracts + inserts timeline entries', async () => {
|
|
const putOp = operationsByName['put_page'];
|
|
const result = await putOp.handler(makeContext(), {
|
|
slug: 'people/dana',
|
|
content: `---
|
|
type: person
|
|
title: Dana
|
|
---
|
|
|
|
Dana is a founder.
|
|
|
|
## Timeline
|
|
|
|
- **2026-03-15** | Shipped v1.0
|
|
- **2026-04-02** | Closed seed round
|
|
`,
|
|
});
|
|
|
|
expect((result as any).auto_timeline).toBeDefined();
|
|
expect((result as any).auto_timeline.created).toBe(2);
|
|
|
|
const entries = await engine.getTimeline('people/dana');
|
|
expect(entries.length).toBe(2);
|
|
const dates = entries.map((e: any) => {
|
|
const d = e.date instanceof Date ? e.date.toISOString().slice(0, 10) : String(e.date).slice(0, 10);
|
|
return d;
|
|
}).sort();
|
|
expect(dates).toEqual(['2026-03-15', '2026-04-02']);
|
|
});
|
|
|
|
test('auto-timeline is idempotent: re-write does not duplicate entries', async () => {
|
|
const putOp = operationsByName['put_page'];
|
|
const content = `---
|
|
type: person
|
|
title: Eve
|
|
---
|
|
|
|
## Timeline
|
|
|
|
- **2026-03-15** | Shipped
|
|
`;
|
|
await putOp.handler(makeContext(), { slug: 'people/eve', content });
|
|
await putOp.handler(makeContext(), { slug: 'people/eve', content });
|
|
|
|
const entries = await engine.getTimeline('people/eve');
|
|
expect(entries.length).toBe(1);
|
|
});
|
|
|
|
test('auto-timeline respects auto_timeline=false config', async () => {
|
|
await engine.setConfig('auto_timeline', 'false');
|
|
try {
|
|
const putOp = operationsByName['put_page'];
|
|
const result = await putOp.handler(makeContext(), {
|
|
slug: 'people/frank',
|
|
content: `---
|
|
type: person
|
|
title: Frank
|
|
---
|
|
|
|
## Timeline
|
|
|
|
- **2026-03-15** | Something happened
|
|
`,
|
|
});
|
|
expect((result as any).auto_timeline).toBeUndefined();
|
|
const entries = await engine.getTimeline('people/frank');
|
|
expect(entries.length).toBe(0);
|
|
} finally {
|
|
await engine.setConfig('auto_timeline', 'true');
|
|
}
|
|
});
|
|
|
|
test('auto-link respects auto_link=false config', async () => {
|
|
await engine.setConfig('auto_link', 'false');
|
|
try {
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
|
const putOp = operationsByName['put_page'];
|
|
const result = await putOp.handler(makeContext(), {
|
|
slug: 'notes/disabled',
|
|
content: `---
|
|
type: concept
|
|
title: Disabled Auto Link
|
|
---
|
|
|
|
Mention of [Alice](people/alice).
|
|
`,
|
|
});
|
|
|
|
// No auto_links field when disabled (we skip the helper entirely).
|
|
expect((result as any).auto_links).toBeUndefined();
|
|
|
|
const links = await engine.getLinks('notes/disabled');
|
|
expect(links.length).toBe(0);
|
|
} finally {
|
|
await engine.setConfig('auto_link', 'true');
|
|
}
|
|
});
|
|
|
|
test('graph-query end-to-end: traversePaths returns expected edges', async () => {
|
|
await engine.putPage('people/alice', { type: 'person', title: 'Alice', compiled_truth: '', timeline: '' });
|
|
await engine.putPage('people/bob', { type: 'person', title: 'Bob', compiled_truth: '', timeline: '' });
|
|
await engine.putPage('companies/acme', { type: 'company', title: 'Acme', compiled_truth: '', timeline: '' });
|
|
await engine.addLink('people/alice', 'companies/acme', '', 'works_at');
|
|
await engine.addLink('people/bob', 'companies/acme', '', 'invested_in');
|
|
|
|
// "Who works at Acme?" -> direction in, type works_at.
|
|
const paths = await engine.traversePaths('companies/acme', {
|
|
direction: 'in', linkType: 'works_at', depth: 1,
|
|
});
|
|
expect(paths.length).toBe(1);
|
|
expect(paths[0].from_slug).toBe('people/alice');
|
|
expect(paths[0].link_type).toBe('works_at');
|
|
});
|
|
|
|
test('search backlink boost: well-connected pages rank higher', async () => {
|
|
// Create 3 pages all matching a search term, but with different inbound link counts.
|
|
await engine.putPage('topic/popular', {
|
|
type: 'concept', title: 'Popular Topic',
|
|
compiled_truth: 'This is the popular topic about widgets.',
|
|
timeline: '',
|
|
});
|
|
await engine.putPage('topic/medium', {
|
|
type: 'concept', title: 'Medium Topic',
|
|
compiled_truth: 'This is a medium topic about widgets.',
|
|
timeline: '',
|
|
});
|
|
await engine.putPage('topic/obscure', {
|
|
type: 'concept', title: 'Obscure Topic',
|
|
compiled_truth: 'This is an obscure topic about widgets.',
|
|
timeline: '',
|
|
});
|
|
// Create inbound link references so each topic gets a backlink count.
|
|
for (let i = 0; i < 5; i++) {
|
|
await engine.putPage(`ref/popular-${i}`, {
|
|
type: 'concept', title: `Ref ${i}`, compiled_truth: '', timeline: '',
|
|
});
|
|
await engine.addLink(`ref/popular-${i}`, 'topic/popular', '', 'mentions');
|
|
}
|
|
await engine.addLink('ref/popular-0', 'topic/medium', '', 'mentions');
|
|
|
|
// Verify backlink counts.
|
|
const counts = await engine.getBacklinkCounts(['topic/popular', 'topic/medium', 'topic/obscure']);
|
|
expect(counts.get('topic/popular')).toBe(5);
|
|
expect(counts.get('topic/medium')).toBe(1);
|
|
expect(counts.get('topic/obscure')).toBe(0);
|
|
});
|
|
});
|