Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ describe('mapApiError — Wave 0 RED', () => {
expect(err.message).toBe('M');
});

it('404 + WORKSPACE_NOT_FOUND → ApiError hinting workspace use (AIT-51)', async () => {
const { mapApiError } = await import('../client.js');
const err = (await (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mapApiError as any
)(mkRes(404, { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' }))) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.message).toMatch(/workspace use/);
});

it('410 + BILLING_PORTAL_RETIRED → ApiError pointing at billing manage', async () => {
const { mapApiError } = await import('../client.js');
const err = (await (
Expand Down
5 changes: 5 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ export async function mapApiError(res: Response): Promise<CliError> {
// "already spent code"). ApiError.exitCode defaults to 1; we override
// to 5 on the instance so the CLI's exit-code contract stays honest
// ("API rejected the bootstrap code" is a distinct failure class).
// AIT-51: a stale activeWorkspaceId (DB re-seed, deleted workspace) makes
// every workspace-scoped command 404 — point at the recovery command.
if (res.status === 404 && code === 'WORKSPACE_NOT_FOUND') {
return new ApiError(`${msg}. Run: hookmyapp workspace use <name>`, 404);
}
if (res.status === 404 && code === 'BOOTSTRAP_NOT_FOUND') {
const err = new ApiError(
'Code invalid or already used. Ask the dashboard user to click Copy again.',
Expand Down
50 changes: 50 additions & 0 deletions src/commands/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../../auth/store.js', () => ({ readCredentials: vi.fn(async () => null) }));
vi.mock('../../api/client.js', () => ({ apiClient: vi.fn() }));
vi.mock('../workspace.js', () => ({ readWorkspaceConfig: vi.fn(() => ({})) }));
import { readCredentials } from '../../auth/store.js';
import { apiClient } from '../../api/client.js';
import { readWorkspaceConfig } from '../workspace.js';
import { AuthError, NetworkError } from '../../output/error.js';
import { collectDoctorReport } from '../doctor.js';

Expand Down Expand Up @@ -68,3 +70,51 @@ describe('doctor — auth probe uses the real authenticated request path', () =>
expect(report.checks.find((c) => c.id === 'auth')!.detail).toBe('credentials present');
});
});

describe('doctor — active workspace is validated against the backend (AIT-51)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200 })));
vi.mocked(readCredentials).mockResolvedValue({
accessToken: 't',
refreshToken: 'rt',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
vi.mocked(readWorkspaceConfig).mockReturnValue({
activeWorkspaceId: 'ws_stale123',
activeWorkspaceSlug: 'My Workspace',
});
});

it('flags a persisted workspace missing from the backend list as stale', async () => {
vi.mocked(apiClient).mockResolvedValue([{ id: 'ws_other456' }]);

const report = await collectDoctorReport({ checkTools: false });

const ws = report.checks.find((c) => c.id === 'workspace')!;
expect(ws.ok).toBe(false);
expect(ws.detail).toContain('workspace use');
expect(report.ok).toBe(true); // informational, never a hard gate
});

it('passes when the persisted workspace exists on the backend', async () => {
vi.mocked(apiClient).mockResolvedValue([{ id: 'ws_stale123' }]);

const report = await collectDoctorReport({ checkTools: false });

const ws = report.checks.find((c) => c.id === 'workspace')!;
expect(ws.ok).toBe(true);
expect(ws.detail).toBe('My Workspace');
});

it('keeps the cache-based verdict when the workspaces fetch fails', async () => {
vi.mocked(apiClient).mockRejectedValue(new NetworkError());

const report = await collectDoctorReport({ checkTools: false });

const ws = report.checks.find((c) => c.id === 'workspace')!;
expect(ws.ok).toBe(true);
expect(ws.detail).toBe('My Workspace');
});
});
28 changes: 24 additions & 4 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,15 @@ export async function collectDoctorReport(
// stored accessToken false-FAILs on tokens that are merely expired but
// refreshable (2026-07-08 audit).
let authDetail = loggedIn ? 'credentials present' : 'not logged in — run: hookmyapp login';
// Kept from the auth probe so the workspace check below can validate the
// persisted activeWorkspaceId against the backend instead of trusting the
// cache (AIT-51: after a DB re-seed doctor said OK while every scoped
// command 404'd).
let workspaces: Array<{ id?: string }> | null = null;
if (loggedIn && opts.checkNetwork !== false) {
try {
await apiClient('/workspaces');
const res = await apiClient('/workspaces');
if (Array.isArray(res)) workspaces = res;
authDetail = 'credentials valid for this env';
} catch (err) {
if (err instanceof AuthError || err instanceof PermissionError) {
Expand All @@ -75,9 +81,23 @@ export async function collectDoctorReport(
// Informational: not-logged-in is reported, not a hard prereq failure.
checks.push({ id: 'auth', label: 'Logged in', ok: loggedIn, hard: false, detail: authDetail });

let activeWs: string | undefined;
try { activeWs = readWorkspaceConfig().activeWorkspaceSlug ?? undefined; } catch { /* ignore */ }
checks.push({ id: 'workspace', label: 'Active workspace', ok: true, hard: false, detail: activeWs ?? '(none — auto-resolves on first call)' });
let wsId: string | undefined;
let wsSlug: string | undefined;
try {
const cfg = readWorkspaceConfig();
wsId = cfg.activeWorkspaceId ?? undefined;
wsSlug = cfg.activeWorkspaceSlug ?? undefined;
} catch { /* ignore */ }
let wsOk = true;
let wsDetail = wsSlug ?? '(none — auto-resolves on first call)';
// Only a definitive backend list can fail this check — on a network flake
// (workspaces === null) the cache-based verdict stands, matching the auth
// probe's fail-open posture above.
if (wsId && workspaces && !workspaces.some((w) => w?.id === wsId)) {
wsOk = false;
wsDetail = `"${wsSlug ?? wsId}" not found on this env (stale selection) — run: hookmyapp workspace use <name>`;
}
checks.push({ id: 'workspace', label: 'Active workspace', ok: wsOk, hard: false, detail: wsDetail });
const envChannel = process.env.HOOKMYAPP_CHANNEL_ID;
checks.push({ id: 'default-channel', label: 'Default channel (HOOKMYAPP_CHANNEL_ID)', ok: true, hard: false, detail: envChannel || '(none — pass --channel or set HOOKMYAPP_CHANNEL_ID)' });

Expand Down
Loading