fix: stop Reddit's block page reading as a signed-out session
Reddit answers a throttled client with a large HTML 403 interstitial. To the comment client that was indistinguishable from a logged-out session: the body would not parse, so no modhash was found, so it reported "Reddit did not return a modhash for this session" and returned session-invalid — which calls auth.invalidate() and so could disrupt outreach and chat for an account whose session was perfectly healthy. That matches what prod is doing. u/Saira_Makimad posted successfully 25 seconds after boot and every later attempt failed with that message, which is the shape of Reddit starting to throttle rather than a session going bad. A response is now only treated as a verdict about the session when it is actually JSON. Non-JSON, at any status, is Reddit refusing to serve us and stays retryable. A genuine JSON 403, or JSON with no modhash, still invalidates as before. When Reddit does report a signed-out session, the log and the recorded reason now name the cookies visible to the write origin — names and domains only, never values. Cookies scoped to www.reddit.com rather than .reddit.com would produce exactly this symptom, and this makes that answerable from the logs instead of by guesswork. Also clarify the allowance in the dashboard. It is derived from the trust level rather than typed in, and it is a rolling 24-hour window, neither of which the page said. A per-community cap above the account allowance now says how much of it is actually reachable, because "1 of 10 today" next to an account limit of 2 reads as far more headroom than exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f36e93d5be
commit
f88db1ae7d
5 files changed
+240
-18
No files matched your search
@@ -86,9 +86,12 @@ function AccountRow({
|
|||||||
function SubredditRow({
|
function SubredditRow({
|
||||||
accountId,
|
accountId,
|
||||||
subreddit,
|
subreddit,
|
||||||
|
dailyAllowance,
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
subreddit: GrowthSubreddit;
|
subreddit: GrowthSubreddit;
|
||||||
|
/** The account-wide ceiling, which binds before any per-community cap. */
|
||||||
|
dailyAllowance: number;
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateGrowthSubredditMutation(accountId);
|
const update = useUpdateGrowthSubredditMutation(accountId);
|
||||||
const remove = useRemoveGrowthSubredditMutation(accountId);
|
const remove = useRemoveGrowthSubredditMutation(accountId);
|
||||||
@@ -102,9 +105,16 @@ function SubredditRow({
|
|||||||
<div className="growth-subreddit-row">
|
<div className="growth-subreddit-row">
|
||||||
<div className="growth-subreddit-name">
|
<div className="growth-subreddit-name">
|
||||||
<strong>r/{subreddit.subreddit}</strong>
|
<strong>r/{subreddit.subreddit}</strong>
|
||||||
<span>{subreddit.postedLast24h} of {subreddit.dailyCap} today</span>
|
<span>{subreddit.postedLast24h} of {subreddit.dailyCap} in the last 24h</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{subreddit.dailyCap > dailyAllowance ? (
|
||||||
|
<p className="growth-subreddit-note">
|
||||||
|
Only {dailyAllowance} of these can actually be used today — the account allowance is the
|
||||||
|
overall limit, and this cap only stops one community taking all of it.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{subreddit.disabledReason ? (
|
{subreddit.disabledReason ? (
|
||||||
<p className="growth-subreddit-note">
|
<p className="growth-subreddit-note">
|
||||||
Reddit refused this account here: {subreddit.disabledReason}
|
Reddit refused this account here: {subreddit.disabledReason}
|
||||||
@@ -211,12 +221,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
|
|||||||
|
|
||||||
<dl className="growth-metrics">
|
<dl className="growth-metrics">
|
||||||
<div>
|
<div>
|
||||||
<dt>Allowance</dt>
|
<dt>Used in last 24h</dt>
|
||||||
<dd>{stats.postedLast24h} / {settings.dailyAllowance} today</dd>
|
<dd>{stats.postedLast24h} / {settings.dailyAllowance}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Trust level</dt>
|
<dt>Trust level</dt>
|
||||||
<dd>{settings.trustLevel} of 4</dd>
|
<dd>{settings.trustLevel} of 4 → {settings.dailyAllowance}/day</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>Comments live</dt>
|
<dt>Comments live</dt>
|
||||||
@@ -237,9 +247,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<p className="field-hint">
|
<p className="field-hint">
|
||||||
The allowance rises on its own while comments survive and earn upvotes, and falls back
|
The allowance comes from the trust level, not from a setting: level 0 permits 2 comments
|
||||||
the moment one is removed or heavily downvoted. Two removals in a row, or a comment the
|
a day, then 3, 5, 8 and 12. It is a rolling 24-hour window rather than a midnight reset,
|
||||||
public cannot see, pauses the account for you.
|
so capacity frees up 24 hours after each comment. The level rises on its own while
|
||||||
|
comments survive and earn upvotes, and falls back the moment one is removed or heavily
|
||||||
|
downvoted. Two removals in a row, or a comment the public cannot see, pauses the account
|
||||||
|
for you.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="growth-pacing">
|
<div className="growth-pacing">
|
||||||
@@ -331,7 +344,12 @@ function AccountDetail({ account }: { account: GrowthAccountDetail }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="growth-subreddit-list">
|
<div className="growth-subreddit-list">
|
||||||
{account.subreddits.map((subreddit) => (
|
{account.subreddits.map((subreddit) => (
|
||||||
<SubredditRow accountId={accountId} key={subreddit.subreddit} subreddit={subreddit} />
|
<SubredditRow
|
||||||
|
accountId={accountId}
|
||||||
|
dailyAllowance={settings.dailyAllowance}
|
||||||
|
key={subreddit.subreddit}
|
||||||
|
subreddit={subreddit}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ export interface RedditCommentClientDependencies {
|
|||||||
scheduler?: RedditRequestScheduler;
|
scheduler?: RedditRequestScheduler;
|
||||||
anonymousFetch?: AnonymousFetcher;
|
anonymousFetch?: AnonymousFetcher;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
|
/**
|
||||||
|
* Names and domains of the cookies visible to Reddit, never their values.
|
||||||
|
* Included in the log when Reddit reports a signed-out session, because the
|
||||||
|
* usual cause is cookies scoped to a host that this origin cannot see.
|
||||||
|
*/
|
||||||
|
describeCookies?: () => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passthroughScheduler: RedditRequestScheduler = {
|
const passthroughScheduler: RedditRequestScheduler = {
|
||||||
@@ -107,6 +113,7 @@ export class RedditCommentClient {
|
|||||||
private readonly scheduler: RedditRequestScheduler;
|
private readonly scheduler: RedditRequestScheduler;
|
||||||
private readonly anonymousFetch: AnonymousFetcher | null;
|
private readonly anonymousFetch: AnonymousFetcher | null;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
|
private readonly describeCookies: (() => Promise<string>) | null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly transport: RedditWriteTransport,
|
private readonly transport: RedditWriteTransport,
|
||||||
@@ -116,6 +123,7 @@ export class RedditCommentClient {
|
|||||||
this.scheduler = dependencies.scheduler ?? passthroughScheduler;
|
this.scheduler = dependencies.scheduler ?? passthroughScheduler;
|
||||||
this.anonymousFetch = dependencies.anonymousFetch ?? null;
|
this.anonymousFetch = dependencies.anonymousFetch ?? null;
|
||||||
this.now = dependencies.now ?? Date.now;
|
this.now = dependencies.now ?? Date.now;
|
||||||
|
this.describeCookies = dependencies.describeCookies ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +165,12 @@ export class RedditCommentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
// A 403 here is ambiguous between a dead session and a community ban, so
|
// Reddit's anti-abuse interstitial is an HTML 403. It says nothing about
|
||||||
|
// whether we are signed in, so it must not invalidate the account.
|
||||||
|
if (!looksLikeJson(response.body)) {
|
||||||
|
return { status: 'failed', reason: blockPageReason(response) };
|
||||||
|
}
|
||||||
|
// A JSON 403 is ambiguous between a dead session and a community ban, so
|
||||||
// drop the cached modhash and let the next attempt re-derive identity.
|
// drop the cached modhash and let the next attempt re-derive identity.
|
||||||
this.modhash = null;
|
this.modhash = null;
|
||||||
return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' };
|
return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' };
|
||||||
@@ -271,6 +284,13 @@ export class RedditCommentClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A non-JSON body is Reddit refusing to serve us at all — an anti-abuse
|
||||||
|
// interstitial or error page. Reading that as "signed out" would invalidate
|
||||||
|
// a perfectly good session and disrupt outreach, so it stays retryable.
|
||||||
|
if (!looksLikeJson(response.body)) {
|
||||||
|
this.logger.warn(`Growth: ${blockPageReason(response)}`);
|
||||||
|
return { ok: false, kind: 'unreachable', reason: blockPageReason(response) };
|
||||||
|
}
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -293,16 +313,32 @@ export class RedditCommentClient {
|
|||||||
: undefined;
|
: undefined;
|
||||||
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
|
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
|
||||||
if (!modhash) {
|
if (!modhash) {
|
||||||
|
// Reddit answered with real JSON and no modhash, so it genuinely does not
|
||||||
|
// recognise this session. The cookie summary is the fastest way to see
|
||||||
|
// why — most often they are scoped to a host this origin cannot read.
|
||||||
|
const cookies = await this.describeSessionCookies();
|
||||||
|
this.logger.warn(
|
||||||
|
`Growth: Reddit does not recognise this session on ${REDDIT_WRITE_ORIGIN}. Cookies visible: ${cookies}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
kind: 'signed-out',
|
kind: 'signed-out',
|
||||||
reason: 'Reddit did not return a modhash for this session',
|
reason: `Reddit did not return a modhash for this session (cookies visible: ${cookies})`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.modhash = modhash;
|
this.modhash = modhash;
|
||||||
return { ok: true, modhash };
|
return { ok: true, modhash };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async describeSessionCookies(): Promise<string> {
|
||||||
|
if (!this.describeCookies) return 'not inspected';
|
||||||
|
try {
|
||||||
|
return await this.describeCookies();
|
||||||
|
} catch (error) {
|
||||||
|
return `could not be read (${errorMessage(error)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private classifyError(
|
private classifyError(
|
||||||
error: { code: string; message: string },
|
error: { code: string; message: string },
|
||||||
subreddit: string,
|
subreddit: string,
|
||||||
@@ -490,6 +526,17 @@ function readListing(payload: unknown): Map<string, { score: number | null; remo
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reddit serves JSON for real answers and HTML when it is blocking or erroring. */
|
||||||
|
function looksLikeJson(body: string): boolean {
|
||||||
|
const start = body.trimStart();
|
||||||
|
return start.startsWith('{') || start.startsWith('[');
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockPageReason(response: HttpTextResponse): string {
|
||||||
|
return `Reddit served a non-JSON HTTP ${response.status} response `
|
||||||
|
+ `(${response.body.length} bytes), which is an anti-abuse block page rather than a session verdict`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJson(body: string): unknown {
|
function parseJson(body: string): unknown {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(body) as unknown;
|
return JSON.parse(body) as unknown;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../database/account-repositories.js';
|
} from '../database/account-repositories.js';
|
||||||
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
|
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
|
||||||
import {
|
import {
|
||||||
|
REDDIT_WRITE_ORIGIN,
|
||||||
RedditCommentClient,
|
RedditCommentClient,
|
||||||
createAnonymousFetcher,
|
createAnonymousFetcher,
|
||||||
createBrowserWriteTransport,
|
createBrowserWriteTransport,
|
||||||
@@ -258,6 +259,13 @@ async function buildRuntime(
|
|||||||
{
|
{
|
||||||
scheduler: redditRequestCoordinator,
|
scheduler: redditRequestCoordinator,
|
||||||
anonymousFetch: createAnonymousFetcher(accountConfig.reddit.userAgent),
|
anonymousFetch: createAnonymousFetcher(accountConfig.reddit.userAgent),
|
||||||
|
// Names and domains only — never cookie values. This is the fastest way
|
||||||
|
// to see why Reddit stopped recognising a session on the write origin.
|
||||||
|
describeCookies: async () => {
|
||||||
|
const cookies = await browserClient.getContext()?.cookies(REDDIT_WRITE_ORIGIN) ?? [];
|
||||||
|
if (cookies.length === 0) return `none visible to ${REDDIT_WRITE_ORIGIN}`;
|
||||||
|
return cookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(', ');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const growthService = new AccountGrowthService({
|
const growthService = new AccountGrowthService({
|
||||||
|
|||||||
@@ -195,20 +195,64 @@ describe('GrowthPage', () => {
|
|||||||
it('surfaces the allowance, trust level and comment health', async () => {
|
it('surfaces the allowance, trust level and comment health', async () => {
|
||||||
renderGrowth();
|
renderGrowth();
|
||||||
|
|
||||||
expect(await screen.findByText('1 / 3 today')).toBeVisible();
|
expect(await screen.findByText('1 / 3')).toBeVisible();
|
||||||
expect(screen.getByText('1 of 4')).toBeVisible();
|
expect(screen.getByText('Used in last 24h')).toBeVisible();
|
||||||
|
expect(screen.getByText('1 of 4 → 3/day')).toBeVisible();
|
||||||
// 6 posted less 1 removed.
|
// 6 posted less 1 removed.
|
||||||
expect(screen.getByText('5')).toBeVisible();
|
expect(screen.getByText('5')).toBeVisible();
|
||||||
expect(screen.getByText('4')).toBeVisible();
|
expect(screen.getByText('4')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('explains that the allowance is adaptive rather than fixed', async () => {
|
it('explains where the allowance comes from and that it is a rolling window', async () => {
|
||||||
renderGrowth();
|
renderGrowth();
|
||||||
|
|
||||||
expect(await screen.findByText(/allowance rises on its own/i)).toBeVisible();
|
// The number is derived from the trust level, not typed in by the operator,
|
||||||
|
// which is the thing most likely to confuse someone reading the page.
|
||||||
|
expect(await screen.findByText(/level 0 permits 2 comments a day/i)).toBeVisible();
|
||||||
|
expect(screen.getByText(/rolling 24-hour window rather than a midnight reset/i)).toBeVisible();
|
||||||
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
|
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns when a community cap exceeds what the account allowance permits', async () => {
|
||||||
|
detailPayload = detail({
|
||||||
|
settings: settings({ trustLevel: 0, dailyAllowance: 2 }),
|
||||||
|
subreddits: [{
|
||||||
|
subreddit: 'AskUK',
|
||||||
|
enabled: true,
|
||||||
|
weight: 1,
|
||||||
|
dailyCap: 10,
|
||||||
|
disabledReason: null,
|
||||||
|
cooldownUntil: null,
|
||||||
|
addedAt: 1,
|
||||||
|
postedLast24h: 1,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
renderGrowth();
|
||||||
|
|
||||||
|
// A cap of 10 with an allowance of 2 reads as "10 available" otherwise.
|
||||||
|
expect(await screen.findByText(/Only 2 of these can actually be used today/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not warn when the cap sits within the allowance', async () => {
|
||||||
|
detailPayload = detail({
|
||||||
|
settings: settings({ dailyAllowance: 5 }),
|
||||||
|
subreddits: [{
|
||||||
|
subreddit: 'AskUK',
|
||||||
|
enabled: true,
|
||||||
|
weight: 1,
|
||||||
|
dailyCap: 2,
|
||||||
|
disabledReason: null,
|
||||||
|
cooldownUntil: null,
|
||||||
|
addedAt: 1,
|
||||||
|
postedLast24h: 0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
renderGrowth();
|
||||||
|
|
||||||
|
expect(await screen.findByText('r/AskUK')).toBeVisible();
|
||||||
|
expect(screen.queryByText(/can actually be used today/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles growth off through the API', async () => {
|
it('toggles growth off through the API', async () => {
|
||||||
renderGrowth();
|
renderGrowth();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
@@ -175,9 +175,9 @@ describe('RedditCommentClient.postComment', () => {
|
|||||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed');
|
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([401, 403])('treats HTTP %i on the identity check as signed out', async (status) => {
|
it.each([401, 403])('treats a JSON HTTP %i on the identity check as signed out', async (status) => {
|
||||||
const client = new RedditCommentClient(
|
const client = new RedditCommentClient(
|
||||||
fakeTransport({ get: () => ({ status, body: '' }) }),
|
fakeTransport({ get: () => ({ status, body: '{"error":' + status + '}' }) }),
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -292,8 +292,10 @@ describe('RedditCommentClient.postComment', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('HTTP failures', () => {
|
describe('HTTP failures', () => {
|
||||||
it('treats 403 as an invalid session and clears the cached modhash', async () => {
|
it('treats a JSON 403 as an invalid session and clears the cached modhash', async () => {
|
||||||
const transport = fakeTransport({ post: () => ({ status: 403, body: '' }) });
|
// A bodiless or HTML 403 is Reddit blocking us; only a JSON 403 is a
|
||||||
|
// statement about the session. See the block-page tests below.
|
||||||
|
const transport = fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) });
|
||||||
const client = new RedditCommentClient(transport, logger);
|
const client = new RedditCommentClient(transport, logger);
|
||||||
|
|
||||||
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid');
|
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid');
|
||||||
@@ -459,6 +461,109 @@ describe('RedditCommentClient.fetchPubliclyVisible', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Reddit anti-abuse block pages', () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
// Reddit serves a large HTML interstitial with a 403 when it throttles a
|
||||||
|
// client. It looks identical to a signed-out session unless the body is
|
||||||
|
// inspected, and misreading it invalidates a healthy account's auth.
|
||||||
|
const BLOCK_PAGE = { status: 403, body: '<html><body>' + 'x'.repeat(2_000) + '</body></html>' };
|
||||||
|
|
||||||
|
it('treats an HTML 403 on the identity check as retryable, not signed out', async () => {
|
||||||
|
const client = new RedditCommentClient(fakeTransport({ get: () => BLOCK_PAGE }), logger);
|
||||||
|
|
||||||
|
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an HTML 403 on the comment itself as retryable', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ post: () => BLOCK_PAGE }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result).toMatchObject({ reason: expect.stringContaining('anti-abuse block page') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours a genuine JSON 403 on the comment as an invalid session', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ post: () => ({ status: 403, body: '{"message":"Forbidden"}' }) }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('session-invalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a non-JSON 200 as retryable too', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ get: () => ({ status: 200, body: '<html>are you a robot?</html>' }) }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect((await client.postComment('t3_a', 'cats', 'text')).status).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('signed-out session diagnostics', () => {
|
||||||
|
const logger = createMockLogger();
|
||||||
|
|
||||||
|
it('reports which cookies were visible when Reddit does not recognise the session', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }),
|
||||||
|
logger,
|
||||||
|
{ describeCookies: async () => 'reddit_session@www.reddit.com, token_v2@www.reddit.com' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||||
|
|
||||||
|
expect(result.status).toBe('session-invalid');
|
||||||
|
// The cookie host is the usual cause, so it belongs in the reason.
|
||||||
|
expect(result).toMatchObject({ reason: expect.stringContaining('www.reddit.com') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaks cookie values, only what the caller chose to describe', async () => {
|
||||||
|
const describeCookies = vi.fn(async () => 'reddit_session@.reddit.com');
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }),
|
||||||
|
logger,
|
||||||
|
{ describeCookies },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||||
|
|
||||||
|
expect(describeCookies).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result).toMatchObject({ reason: expect.stringContaining('reddit_session@.reddit.com') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a cookie description that throws', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }),
|
||||||
|
logger,
|
||||||
|
{ describeCookies: async () => { throw new Error('context closed'); } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.postComment('t3_a', 'cats', 'text');
|
||||||
|
|
||||||
|
expect(result.status).toBe('session-invalid');
|
||||||
|
expect(result).toMatchObject({ reason: expect.stringContaining('context closed') });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when no diagnostic is wired up', async () => {
|
||||||
|
const client = new RedditCommentClient(
|
||||||
|
fakeTransport({ get: () => ({ status: 200, body: JSON.stringify({ data: { modhash: '' } }) }) }),
|
||||||
|
logger,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await client.postComment('t3_a', 'cats', 'text')).toMatchObject({
|
||||||
|
reason: expect.stringContaining('not inspected'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createBrowserWriteTransport', () => {
|
describe('createBrowserWriteTransport', () => {
|
||||||
/** Mirrors production: a freshly opened Playwright page is on about:blank. */
|
/** Mirrors production: a freshly opened Playwright page is on about:blank. */
|
||||||
function fakePage(initialUrl = 'about:blank') {
|
function fakePage(initialUrl = 'about:blank') {
|
||||||
|
|||||||
Reference in new issue
Block a user