Skip to content
Draft
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
43 changes: 43 additions & 0 deletions src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,49 @@ describe('TUI auth task: region determines OAuth zone', () => {
}),
);
});

test('uses in-memory signupAuth tokens instead of disk or browser OAuth after TUI signup', async () => {
let storedCallback: (() => void) | null = null;
(mockStore.subscribe as any).mockImplementation((cb: () => void) => {
if (!storedCallback) storedCallback = cb;
return vi.fn();
});

const cliPromise = runCLI(['--auth-onboarding', 'create-account']);

await new Promise((r) => setTimeout(r, 50));
mockStore.session = {
...mockStore.session,
authOnboardingPath: 'create_account',
introConcluded: true,
region: 'us',
regionForced: false,
signupTokensObtained: true,
signupAuth: {
idToken: 'direct-id',
accessToken: 'direct-access',
refreshToken: 'direct-refresh',
zone: 'us',
userInfo: null,
dashboardUrl: null,
},
signupAbandoned: false,
};
(storedCallback as (() => void) | null)?.();

await cliPromise;
await waitFor(() => mockStore.setOAuthComplete.mock.calls.length > 0);

expect(mockGetStoredToken).not.toHaveBeenCalled();
expect(mockPerformAmplitudeAuth).not.toHaveBeenCalled();
expect(mockStore.setOAuthComplete).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: 'direct-access',
idToken: 'direct-id',
cloudRegion: 'us',
}),
);
});
});

// ── Feature discovery ──────────────────────────────────────────────────────────
Expand Down
55 changes: 21 additions & 34 deletions src/commands/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,9 +636,7 @@ export const defaultCommand: CommandModule = {
const { DEFAULT_AMPLITUDE_ZONE } = await import(
'../lib/constants.js'
);
const { storeToken, getStoredToken } = await import(
'../utils/ampli-settings.js'
);
const { storeToken } = await import('../utils/ampli-settings.js');

// Wait for the user to dismiss the welcome screen AND pick a
// region before opening the OAuth URL. This ensures the logo
Expand Down Expand Up @@ -719,14 +717,11 @@ export const defaultCommand: CommandModule = {
// response — in which case we fall through to the existing OAuth flow
// (TUI has a browser; this fallback is valid).
//
// On signup success, the wrapper already fetched the real user
// profile (with provisioning retry) and persisted tokens to
// ~/.ampli.json. SigningUpScreen mirrors the wrapper-fetched
// userInfo onto `session.signupAuth.userInfo`, so reading it
// here lets us skip the redundant fetch + storeToken below.
// When the wrapper's fetch failed (provisioning lag exhausted
// retries), `signupAuth.userInfo` is null and we fall through
// to the probe path — same outcome as before.
// On signup success, SigningUpScreen captured fresh tokens in
// session. Use those in-memory tokens as the immediate handoff;
// disk persistence is a side effect, not coordination state.
// If wrapper-fetched userInfo is present too, skip the redundant
// fetch + storeToken below.
let auth: Awaited<
ReturnType<typeof performAmplitudeAuth>
> | null = null;
Expand All @@ -743,32 +738,24 @@ export const defaultCommand: CommandModule = {
'../utils/signup-or-auth.js'
);
const s = tui.store.session;
if (s.signupTokensObtained) {
if (s.signupTokensObtained && s.signupAuth !== null) {
// SigningUpScreen settled the ceremony successfully:
// `performSignupOrAuth` called `replaceStoredUser`, and
// `setSignupAuth(non-null)` folded in
// `signupTokensObtained=true` atomically. Hydrate `auth`
// from disk here so `performAmplitudeAuth({ forceFresh })`
// below doesn't run on a fresh install dir and skip
// `~/.ampli.json` — that would open a spurious browser
// OAuth even though we already have valid tokens.
// `signupTokensObtained=true` atomically.
signupTokensObtained = true;
const fromDisk = getStoredToken(undefined, zone);
if (fromDisk) {
auth = {
idToken: fromDisk.idToken,
accessToken: fromDisk.accessToken,
refreshToken: fromDisk.refreshToken,
zone,
};
getUI().log.info(
'Using signup tokens obtained during the signup ceremony.',
);
} else {
getUI().log.warn(
'Signup tokens were recorded but none found on disk; opening OAuth.',
);
}
auth = {
idToken: s.signupAuth.idToken,
accessToken: s.signupAuth.accessToken,
refreshToken: s.signupAuth.refreshToken,
zone: s.signupAuth.zone,
};
getUI().log.info(
'Using signup tokens obtained during the signup ceremony.',
);
} else if (s.signupTokensObtained) {
getUI().log.warn(
'Signup tokens were recorded but signupAuth was missing; opening OAuth.',
);
}
// Otherwise: ceremony abandoned (`signupAbandoned=true`) or
// sign-in path. Auth gate would not have released without
Expand Down
8 changes: 8 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,15 @@ export class ApiError extends Error {
* `projects` at this boundary so the rest of the wizard only sees the
* user-facing terminology.
*/
export interface FetchAmplitudeUserOptions {
timeoutMs?: number;
signal?: AbortSignal;
}

export async function fetchAmplitudeUser(
idToken: string,
zone: AmplitudeZone,
options: FetchAmplitudeUserOptions = {},
): Promise<AmplitudeUserInfo> {
const { dataApiUrl } = AMPLITUDE_ZONE_SETTINGS[zone];
try {
Expand All @@ -178,6 +184,8 @@ export async function fetchAmplitudeUser(
'Content-Type': 'application/json',
'User-Agent': WIZARD_USER_AGENT,
},
timeout: options.timeoutMs,
signal: options.signal,
},
);

Expand Down
32 changes: 29 additions & 3 deletions src/ui/tui/screens/SigningUpScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ interface SigningUpScreenProps {
store: WizardStore;
}

function requiredFieldsSatisfied(
requiredFields: string[] | null,
fullName: string | null,
): boolean {
if (requiredFields === null) return false;
return requiredFields.every((field) =>
field === 'full_name' ? fullName !== null : false,
);
}

export const SigningUpScreen = ({ store }: SigningUpScreenProps) => {
useWizardStore(store);

Expand All @@ -54,7 +64,7 @@ export const SigningUpScreen = ({ store }: SigningUpScreenProps) => {
// explicitly fixing. Without ToS, send email-only and let the server
// route us to needs_information so the ToS screen renders next.
const fullName =
session.tosAccepted === true ? (session.signupFullName ?? null) : null;
session.tosAccepted === true ? session.signupFullName ?? null : null;

useAsyncEffect(
async (signal) => {
Expand Down Expand Up @@ -87,6 +97,21 @@ export const SigningUpScreen = ({ store }: SigningUpScreenProps) => {
// error makes the contributor pick a behavior on purpose.
switch (result.kind) {
case 'success':
if (
session.tosAccepted !== true ||
!requiredFieldsSatisfied(session.signupRequiredFields, fullName)
) {
log.warn(
'signup: server returned success before required ceremony inputs were satisfied; abandoning',
{
hasRequiredFields: session.signupRequiredFields !== null,
tosAccepted: session.tosAccepted,
hasFullName: fullName !== null,
},
);
store.setSignupAbandoned(true);
return;
}
// `setSignupAuth` folds in `signupTokensObtained=true`
// atomically — the TUI auth-task gate releases on
// `signupAuth` and reads `signupTokensObtained`; both must
Expand Down Expand Up @@ -120,8 +145,9 @@ export const SigningUpScreen = ({ store }: SigningUpScreenProps) => {
// `full_name` and the server is still asking for it), but
// the cost of the guard is one branch and the failure mode
// it prevents has zero in-band recovery.
const alreadySatisfied = result.requiredFields.every((field) =>
field === 'full_name' ? fullName !== null : false,
const alreadySatisfied = requiredFieldsSatisfied(
result.requiredFields,
fullName,
);
if (alreadySatisfied) {
log.warn(
Expand Down
51 changes: 51 additions & 0 deletions src/ui/tui/screens/__tests__/SigningUpScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { SigningUpScreen } from '../SigningUpScreen.js';
import { makeStoreForSnapshot } from '../../__tests__/snapshot-utils.js';
import { waitForFrame } from '../../__tests__/ink-stdin.js';

const performSignupOrAuth = vi.hoisted(() => vi.fn());

vi.mock('../../../../utils/signup-or-auth.js', () => ({
performSignupOrAuth,
}));

describe('SigningUpScreen', () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('abandons instead of accepting success from the email-only probe before ToS', async () => {
performSignupOrAuth.mockResolvedValue({
kind: 'success',
idToken: 'direct-id',
accessToken: 'direct-access',
refreshToken: 'direct-refresh',
zone: 'us',
userInfo: null,
dashboardUrl: null,
});
const store = makeStoreForSnapshot({
region: 'us',
signupEmail: 'ada@example.com',
signupFullName: null,
signupRequiredFields: null,
tosAccepted: null,
});
const setSignupAuthSpy = vi.spyOn(store, 'setSignupAuth');
const setSignupAbandonedSpy = vi.spyOn(store, 'setSignupAbandoned');

const view = render(<SigningUpScreen store={store} />);
await waitForFrame();
await waitForFrame();

expect(setSignupAuthSpy).not.toHaveBeenCalled();
expect(setSignupAbandonedSpy).toHaveBeenCalledWith(true);
view.unmount();
});
});
56 changes: 56 additions & 0 deletions src/utils/__tests__/direct-signup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,31 @@ describe('performDirectSignup', () => {
expect(result.kind).toBe('error');
});

it('returns aborted when the provisioning POST is cancelled by the caller', async () => {
const controller = new AbortController();
vi.spyOn(axios, 'post').mockImplementation(async () => {
controller.abort();
const err = new Error('canceled') as Error & { code?: string };
err.code = 'ERR_CANCELED';
throw err;
});

try {
const result = await performDirectSignup({
...INPUT,
signal: controller.signal,
});

expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.code).toBe('aborted');
expect(result.message).toBe('aborted');
}
} finally {
vi.restoreAllMocks();
}
});

it('routes EU requests to app.eu.amplitude.com', async () => {
let observedUrl = '';
server.use(
Expand Down Expand Up @@ -427,6 +452,37 @@ describe('performDirectSignup', () => {
}
});

it('returns aborted when the token exchange POST is cancelled by the caller', async () => {
const controller = new AbortController();
vi.spyOn(axios, 'post').mockImplementation(async (url: string) => {
if (url.includes('/t/agentic/signup/v1')) {
return {
status: 200,
data: { type: 'oauth', oauth: { code: 'auth-code-xyz' } },
};
}
controller.abort();
const err = new Error('canceled') as Error & { code?: string };
err.code = 'ERR_CANCELED';
throw err;
});

try {
const result = await performDirectSignup({
...INPUT,
signal: controller.signal,
});

expect(result.kind).toBe('error');
if (result.kind === 'error') {
expect(result.code).toBe('aborted');
expect(result.message).toBe('aborted');
}
} finally {
vi.restoreAllMocks();
}
});

it('returns error with parsed OAuth error on 400 token exchange response', async () => {
server.use(
http.post(PROVISIONING_URL, () =>
Expand Down
Loading