Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
135 lines
5.1 KiB
JavaScript
135 lines
5.1 KiB
JavaScript
// @vitest-environment node
|
|
/**
|
|
* Unit guard for the Bluesky amplifier-graph handler (Austin recon stack).
|
|
*
|
|
* Pins:
|
|
* - auth flow: createSession on the PDS → bearer → searchPosts on the AppView
|
|
* - normalization: pain text + amplifier metrics + author graph node + permalink
|
|
* - 401 → re-auth-once-and-retry (accessJwt expiry path)
|
|
* - missing creds → clean "not configured" error (no 400 dump)
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
const getSecret = vi.fn();
|
|
vi.mock('../../secrets/getSecret.js', () => ({
|
|
getSecret: (...a) => getSecret(...a),
|
|
}));
|
|
|
|
const { blueskyToolHandlers, __resetBlueskySessionForTests } = await import('../bluesky-tools.js');
|
|
const { bluesky_search } = blueskyToolHandlers;
|
|
|
|
const SESSION_OK = {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ accessJwt: 'jwt-abc', did: 'did:plc:me', handle: 'me.bsky.social' }),
|
|
};
|
|
|
|
function searchResponse(posts, { status = 200, cursor } = {}) {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => ({ posts, ...(cursor ? { cursor } : {}) }),
|
|
text: async () => JSON.stringify({ posts }),
|
|
};
|
|
}
|
|
|
|
const SAMPLE_POST = {
|
|
uri: 'at://did:plc:author/app.bsky.feed.post/3kabc',
|
|
author: { handle: 'amplifier.bsky.social', displayName: 'Amp', did: 'did:plc:author' },
|
|
record: { text: ' bioinformatics pipelines are pain ', createdAt: '2026-06-10T12:00:00Z', langs: ['en'] },
|
|
likeCount: 42,
|
|
repostCount: 7,
|
|
replyCount: 3,
|
|
quoteCount: 1,
|
|
indexedAt: '2026-06-10T12:01:00Z',
|
|
};
|
|
|
|
beforeEach(() => {
|
|
getSecret.mockReset();
|
|
__resetBlueskySessionForTests();
|
|
// default: creds present
|
|
getSecret.mockImplementation(async (key) =>
|
|
key === 'bluesky_handle' ? 'me.bsky.social' : 'app-pass-1234'
|
|
);
|
|
});
|
|
|
|
describe('bluesky_search', () => {
|
|
it('authenticates via the PDS then searches the AppView, normalizing amplifier signal', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(SESSION_OK) // createSession
|
|
.mockResolvedValueOnce(searchResponse([SAMPLE_POST], { cursor: 'next' })); // searchPosts
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await bluesky_search({ query: 'bioinformatics pain' });
|
|
|
|
// first call = createSession on the PDS, POST with the app-password
|
|
const [sessionUrl, sessionInit] = fetchMock.mock.calls[0];
|
|
expect(sessionUrl).toMatch(/bsky\.social\/xrpc\/com\.atproto\.server\.createSession/);
|
|
expect(sessionInit.method).toBe('POST');
|
|
expect(JSON.parse(sessionInit.body)).toEqual({ identifier: 'me.bsky.social', password: 'app-pass-1234' });
|
|
|
|
// second call = searchPosts on the AppView, bearer from the session
|
|
const [searchUrl, searchInit] = fetchMock.mock.calls[1];
|
|
expect(searchUrl).toMatch(/api\.bsky\.app\/xrpc\/app\.bsky\.feed\.searchPosts/);
|
|
expect(searchUrl).toMatch(/sort=top/);
|
|
expect(searchInit.headers.Authorization).toBe('Bearer jwt-abc');
|
|
|
|
expect(out.success).toBe(true);
|
|
expect(out.provider).toBe('bluesky');
|
|
expect(out.cursor).toBe('next');
|
|
const r = out.results[0];
|
|
expect(r.text).toBe('bioinformatics pipelines are pain'); // trimmed
|
|
expect(r.author.handle).toBe('amplifier.bsky.social');
|
|
expect(r.amplifier).toEqual({ likes: 42, reposts: 7, replies: 3, quotes: 1 });
|
|
expect(r.url).toBe('https://bsky.app/profile/amplifier.bsky.social/post/3kabc');
|
|
expect(r.langs).toEqual(['en']);
|
|
});
|
|
|
|
it('caps limit at 100 and forwards sort=latest', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(SESSION_OK)
|
|
.mockResolvedValueOnce(searchResponse([]));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await bluesky_search({ query: 'x', limit: 9999, sort: 'latest' });
|
|
const searchUrl = fetchMock.mock.calls[1][0];
|
|
expect(searchUrl).toMatch(/limit=100/);
|
|
expect(searchUrl).toMatch(/sort=latest/);
|
|
});
|
|
|
|
it('re-authenticates once on a 401 and retries the search', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(SESSION_OK) // createSession #1
|
|
.mockResolvedValueOnce(searchResponse([], { status: 401 })) // search → expired
|
|
.mockResolvedValueOnce(SESSION_OK) // createSession #2 (forced)
|
|
.mockResolvedValueOnce(searchResponse([SAMPLE_POST])); // search retry ok
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await bluesky_search({ query: 'pain' });
|
|
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
expect(out.success).toBe(true);
|
|
expect(out.results_count).toBe(1);
|
|
});
|
|
|
|
it('returns a clean "not configured" error when creds are absent', async () => {
|
|
getSecret.mockResolvedValue(null);
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const out = await bluesky_search({ query: 'pain' });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(out.error).toMatch(/not configured/i);
|
|
expect(out.hint).toMatch(/seed-bluesky-secrets/);
|
|
});
|
|
|
|
it('rejects an empty query before any network call', async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const out = await bluesky_search({ query: ' ' });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(out.error).toMatch(/non-empty/);
|
|
});
|
|
});
|