fix(assistant): green CI round after the shared ChatGPT plan

- Tests that still knew the ChatGPT sign-in as personal only follow the
  admin's shared plan: the service takes the caller with their role, the
  admin tiles show ChatGPT, the controller tests cover the admin routes.
- The table refused an instance ChatGPT connection with a CHECK, so
  sharing the plan failed. A new migration rebuilds ai_connections
  without that check; the shipped migration stays as it was.
- useChatgptConnect grew past the function size limit: reading the
  clipboard and noticing the return to the tab are helpers of their own.
- The file link factory hands its overrides over apart from the row,
  like every other factory, which ends the deep type instantiation.
- Knip: exports only their own file used are private again, and the
  booking cap reads MAX_WRITES_PER_STEP instead of an alias.
- lint:i18n-keys reaches the tools' own action names through a key
  builder and the connection hints through literal keys.
- MMehrSheet stays in the assistant hints pair only.
This commit is contained in:
Maurice
2026-10-21 21:40:44 +00:00
parent 4abab7a777
commit 8a8ce4013b
23 changed files with 238 additions and 98 deletions
-1
View File
@@ -978,7 +978,6 @@
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
+1 -1
View File
@@ -30,7 +30,7 @@
"planner.ts": 53,
"reservations.ts": 21,
"roadtrip.ts": 38,
"settings.ts": 40,
"settings.ts": 37,
"share.ts": 7,
"shared.ts": 2,
"storage.ts": 16,
@@ -95,7 +95,8 @@ describe('the connection and the assistant', () => {
expect(screen.getByText('Everyone uses the connection below unless they connect their own.')).toBeInTheDocument();
expect(screen.getByText('No connection yet.')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Own server/ })).toBeInTheDocument();
expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument();
// The admin may share their own ChatGPT plan with everyone, so its tile is offered too.
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'AI Assistant' }));
expect(screen.getByDisplayValue('50')).toBeInTheDocument();
expect(screen.getByText('Keep chats')).toBeInTheDocument();
@@ -7,7 +7,7 @@ import type { Addon } from './addonModel';
* and MCP (TREK inside other AI apps). Their ids, rows and settings stay their
* own; only the page groups them. Assistant and parsing share one connection.
*/
export const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
const AI_ADDON_IDS = ['ai_assistant', 'llm_parsing', 'mcp'] as const;
export type AiAddonId = (typeof AI_ADDON_IDS)[number];
/** Each one's glyph, keyed by id: the icon column of an existing install still holds what it was seeded with. */
@@ -230,7 +230,8 @@ describe('AssistantChangeCard: on the map', () => {
fireEvent.mouseEnter(screen.getByText('Ryoan-ji'));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
const row = screen.getByText('Pantheon').closest('[class*="py-2.5"]');
const row = screen.getByText('Pantheon').closest('[class*="rounded-[12px]"]');
expect(row).not.toBeNull();
expect(row).not.toHaveClass('bg-surface-hover');
act(() => useAssistantStore.setState({ mapLink: { tripId: TRIP, hoveredId: 'c1', source: 'map' } }));
expect(row).toHaveClass('bg-surface-hover');
@@ -123,10 +123,12 @@ function KindTile({
);
}
/** The verb as a quiet line over the name of what it touches. */
/** The key of a tool's own action name, for the tools TREK names one for. */
const toolKey = (tool: string) => `assistant.tool.${tool}`;
/** What the change does: the tool's own action where TREK names it, else add, change or remove. */
function verbLabel(preview: AssistantChangePreview, t: (key: string) => string): string {
const own = preview.tool ? `assistant.tool.${preview.tool}` : null;
const own = preview.tool ? toolKey(preview.tool) : null;
const named = own ? t(own) : null;
return named && named !== own ? named : t(`assistant.change.verb.${preview.verbKey}`);
}
@@ -6,7 +6,7 @@ import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads (the server checks again), and how big one file may be. */
export const ATTACH_EXTENSIONS: readonly string[] = [
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
'.pkpass',
@@ -1,4 +1,4 @@
// FE-COMP-AI-CONNECT-PANEL-001 to FE-COMP-AI-CONNECT-PANEL-008: the two tiles, the key field, the ChatGPT paste step.
// FE-COMP-AI-CONNECT-PANEL-001 to FE-COMP-AI-CONNECT-PANEL-009: the mode tiles, the key field, the ChatGPT paste step.
import userEvent from '@testing-library/user-event';
import type { AiStatusResponse } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -63,7 +63,7 @@ describe('AiConnectPanel', () => {
expect(repo.chatgptStart).not.toHaveBeenCalled();
});
it('FE-COMP-AI-CONNECT-PANEL-002: the admin picks between a key and an own server, prefilled from AI parsing', () => {
it('FE-COMP-AI-CONNECT-PANEL-002: the admin picks between a key, their ChatGPT plan and an own server, prefilled from AI parsing', () => {
render(
<AiConnectPanel
scope="instance"
@@ -71,7 +71,9 @@ describe('AiConnectPanel', () => {
prefill={{ kind: 'custom', baseUrl: 'http://ollama:11434/v1', model: 'qwen3:8b' }}
/>
);
expect(screen.queryByText('Sign in with ChatGPT')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Paste an API key/ })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: /Sign in with ChatGPT/ })).toHaveAttribute('aria-pressed', 'false');
expect(repo.chatgptStart).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /Own server/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByText('Taken over from AI Parsing.')).toBeInTheDocument();
expect(screen.getByDisplayValue('http://ollama:11434/v1')).toBeInTheDocument();
@@ -183,4 +185,22 @@ describe('AiConnectPanel', () => {
render(<AiConnectPanel scope="user" onConnected={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Using Claude? Connect in Claude' })).toBeInTheDocument();
});
it('FE-COMP-AI-CONNECT-PANEL-009: the admin signing in with ChatGPT is told the plan is shared, and the sign-in is for everyone', async () => {
const user = userEvent.setup();
const { unmount } = render(<AiConnectPanel scope="user" onConnected={vi.fn()} initialMode="chatgpt" />);
await waitFor(() => expect(repo.chatgptStart).toHaveBeenCalledWith('user'));
expect(screen.queryByRole('note')).not.toBeInTheDocument();
unmount();
render(<AiConnectPanel scope="instance" onConnected={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /Sign in with ChatGPT/ }));
expect(screen.getByRole('note')).toHaveTextContent(
'You share your own ChatGPT plan with everyone on this server: their chats count against your plan.'
);
await waitFor(() =>
expect(screen.getByRole('link', { name: /Open ChatGPT sign-in/ })).toHaveAttribute('href', FLOW.authorizeUrl)
);
expect(repo.chatgptStart).toHaveBeenLastCalledWith('instance');
});
});
@@ -384,11 +384,11 @@ function prefillModeOf(prefill: AiParsingPrefill | null): AiConnectMode | null {
}
/**
* Connecting a provider in under a minute: two tiles, and the form of the one
* picked below them. A user picks between signing in with ChatGPT and pasting
* a key; the admin, setting up the connection for everyone, between a key and
* an own server. ChatGPT is never shared across an instance, an own server
* never named by a user (#1772): the server refuses both, so they are not offered.
* Connecting a provider in under a minute: a tile for each way, and the form
* of the one picked below them. A user picks between signing in with ChatGPT
* and pasting a key; the admin, setting up the connection for everyone, between
* a key, their own ChatGPT plan and an own server. An own server is never named
* by a user (#1772): the server refuses it, so it is not offered.
*/
export default function AiConnectPanel({ scope, onConnected, initialMode, prefill = null }: AiConnectPanelProps) {
const { t } = useTranslation();
@@ -128,8 +128,6 @@ export function svgDataUrl(svg: string): string {
*/
export function aiSectionText(assistant: boolean, parsing: boolean): { titleKey: string; hintKey: string } {
if (!assistant) return { titleKey: 'settings.aiParsing.title', hintKey: 'assistant.connect.descriptionParsingOnly' };
return {
titleKey: 'assistant.connect.title',
hintKey: parsing ? 'assistant.connect.descriptionWithParsing' : 'assistant.connect.description',
};
if (parsing) return { titleKey: 'assistant.connect.title', hintKey: 'assistant.connect.descriptionWithParsing' };
return { titleKey: 'assistant.connect.title', hintKey: 'assistant.connect.description' };
}
@@ -1,5 +1,5 @@
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-012: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
import { act, renderHook, waitFor } from '@testing-library/react';
// FE-HOOK-AI-CONNECT-001 to FE-HOOK-AI-CONNECT-013: pasting a key, an own server, the ChatGPT sign-in and the OpenRouter return.
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react';
import { createElement, type ReactNode } from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -218,6 +218,37 @@ describe('useChatgptConnect', () => {
expect(onConnected).not.toHaveBeenCalled();
expect(repo.chatgptStart).toHaveBeenCalledTimes(1);
});
it('FE-HOOK-AI-CONNECT-013: the clipboard fills the field where the browser lets it be read, else the field gets the cursor', async () => {
const readText = vi.fn(async () => 'not an address');
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { readText } });
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true });
const { result } = renderHook(() => useChatgptConnect({ active: true, onConnected: vi.fn() }), {
wrapper: wrapperAt(),
});
await waitFor(() => expect(result.current.flow).not.toBeNull());
const input = document.createElement('input');
document.body.append(input);
result.current.inputRef.current = input;
act(() => result.current.pasteFromClipboard());
await waitFor(() => expect(result.current.pasted).toBe('not an address'));
expect(repo.chatgptFinish).not.toHaveBeenCalled();
// Without a readable clipboard the button and the return from the sign-in window both focus the field.
Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false });
act(() => result.current.pasteFromClipboard());
expect(document.activeElement).toBe(input);
input.blur();
act(() => {
result.current.change('');
result.current.markOpened();
});
fireEvent.focus(window);
expect(document.activeElement).toBe(input);
expect(readText).toHaveBeenCalledTimes(1);
input.remove();
});
});
describe('OpenRouter', () => {
@@ -150,6 +150,35 @@ export function useCustomConnect({
return { baseUrl, setBaseUrl, apiKey, setApiKey, model, setModel, host, ready, saving, error, submit };
}
/**
* Hands `onText` what the clipboard holds where the browser lets the page read
* it; where it does not, or the user refuses, `onUnreadable` runs instead.
*/
function readClipboard(onText: (text: string) => void, onUnreadable: () => void) {
if (!navigator.clipboard?.readText || !window.isSecureContext) {
onUnreadable();
return;
}
navigator.clipboard.readText().then(onText, onUnreadable);
}
/** Runs `onBack` each time the user comes back to this tab while `watching` holds. */
function useOnReturnToTab(watching: boolean, onBack: () => void) {
const back = useEffectEvent(onBack);
useEffect(() => {
if (!watching) return;
const onVisible = () => {
if (document.visibilityState === 'visible') back();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
}, [watching]);
}
/**
* Signing in with ChatGPT (K3). The sign-in address is fetched as soon as the
* step shows, so opening it is a plain link and no popup blocker interferes.
@@ -241,47 +270,22 @@ export function useChatgptConnect({
return true;
};
const focusField = () => inputRef.current?.focus();
/** Reads the address off the clipboard where the browser allows it, else hands the field the cursor. */
const pasteFromClipboard = () => {
if (!navigator.clipboard?.readText || !window.isSecureContext) {
inputRef.current?.focus();
return;
}
navigator.clipboard.readText().then(
(text) => {
if (!paste(text)) change(text);
},
() => inputRef.current?.focus()
);
};
const pasteFromClipboard = () =>
readClipboard((text) => {
if (!paste(text)) change(text);
}, focusField);
// Back from the sign-in window: the address the user just copied is taken
// off the clipboard where the browser allows it, else the field has the cursor.
const takeCopied = useEffectEvent(() => {
useOnReturnToTab(opened, () => {
if (pasted.trim() || busy) return;
if (!navigator.clipboard?.readText || !window.isSecureContext) {
inputRef.current?.focus();
return;
}
navigator.clipboard.readText().then(
(text) => {
if (!paste(text)) inputRef.current?.focus();
},
() => inputRef.current?.focus()
);
readClipboard((text) => {
if (!paste(text)) focusField();
}, focusField);
});
useEffect(() => {
if (!opened) return;
const onBack = () => {
if (document.visibilityState === 'visible') takeCopied();
};
document.addEventListener('visibilitychange', onBack);
window.addEventListener('focus', onBack);
return () => {
document.removeEventListener('visibilitychange', onBack);
window.removeEventListener('focus', onBack);
};
}, [opened]);
return {
flow,
@@ -0,0 +1,55 @@
import { Migration } from '@mikro-orm/migrations';
/**
* An admin may share their own ChatGPT sign-in with everyone, so a ChatGPT
* connection can be the instance connection too. The table refused that with
* `CHECK(kind <> 'chatgpt' OR scope = 'user')`, and saving the shared plan
* failed on it.
*
* SQLite cannot drop a CHECK in place, so the table is rebuilt without that
* one; every other check stays as it was. Nothing references
* `ai_connections`, so the old table can go before the new one takes its
* name. Ids are copied, so a connection keeps its id.
*/
export class Migration20200101046230_an_admin_can_share_a_chatgpt_plan extends Migration {
override name = 'Migration20200101046230_an_admin_can_share_a_chatgpt_plan';
override up(): void {
this.addSql(`
CREATE TABLE ai_connections_new (
id INTEGER PRIMARY KEY,
scope TEXT NOT NULL CHECK(scope IN ('instance', 'user')),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('anthropic', 'openai', 'gemini', 'openrouter', 'chatgpt', 'custom')),
model TEXT NOT NULL,
base_url TEXT,
secrets TEXT,
secrets_version INTEGER NOT NULL DEFAULT 0,
hint TEXT,
account TEXT,
status TEXT NOT NULL DEFAULT 'ok' CHECK(status IN ('ok', 'needs_reauth', 'signed_out', 'error')),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK((scope = 'user') = (user_id IS NOT NULL)),
CHECK(kind <> 'custom' OR (scope = 'instance' AND base_url IS NOT NULL)),
CHECK(base_url IS NULL OR kind = 'custom')
)
`);
this.addSql(`
INSERT INTO ai_connections_new (
id, scope, user_id, kind, model, base_url, secrets, secrets_version, hint, account, status, created_at, updated_at
)
SELECT
id, scope, user_id, kind, model, base_url, secrets, secrets_version, hint, account, status, created_at, updated_at
FROM ai_connections
`);
this.addSql('DROP TABLE ai_connections');
this.addSql('ALTER TABLE ai_connections_new RENAME TO ai_connections');
this.addSql(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_connections_user ON ai_connections(user_id) WHERE user_id IS NOT NULL',
);
this.addSql(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_connections_instance ON ai_connections(scope) WHERE user_id IS NULL',
);
}
}
@@ -3,7 +3,7 @@ import { BookingImportService } from '../booking-import/booking-import.service';
import { DemoService } from '../common/demo.service';
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { MAX_BOOKINGS_PER_FILE } from './assistant-tools.constants';
import { MAX_WRITES_PER_STEP } from './assistant-tools.constants';
import type { ToolRun } from './assistant-tools.service';
import { Injectable } from '@nestjs/common';
import { bookingImportPreviewItemSchema, LLM_PHOTO_EXTENSIONS, type BookingImportPreviewItem } from '@trek/shared';
@@ -54,7 +54,8 @@ export class AssistantBookingImportService {
async read(userId: number, file: Express.Multer.File): Promise<BookingImportPreviewItem[]> {
const mode = (await this.bookingImport.aiAvailable(userId)) ? 'fallback-on-empty' : 'no-ai';
const preview = await this.bookingImport.preview([file], mode, userId);
return preview.items.slice(0, MAX_BOOKINGS_PER_FILE);
// One file proposes no more bookings than one step may hold changes; the rest are left out.
return preview.items.slice(0, MAX_WRITES_PER_STEP);
}
/**
@@ -3,7 +3,7 @@ import type { AssistantMessagePart } from '@trek/shared';
/** What the model is told about a change the user discarded. */
export const DISCARDED = { applied: false, reason: 'The user discarded this change.' };
/** ... one the user left undecided by asking something else. */
export const CANCELLED = { applied: false, reason: 'The user moved on without deciding.' };
const CANCELLED = { applied: false, reason: 'The user moved on without deciding.' };
/** ... one that was approved but never ran. */
export const NOT_RUN = { applied: false, reason: 'The change could not be run.' };
@@ -163,9 +163,6 @@ export const ASSISTANT_SERVER_TOOLS: ReadonlySet<string> = new Set([
...Object.keys(ASSISTANT_SERVER_WRITE_TOOLS),
]);
/** Bookings one attached file may propose; the rest are left out, as a step holds no more changes. */
export const MAX_BOOKINGS_PER_FILE = MAX_WRITES_PER_STEP;
/** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
@@ -79,7 +79,7 @@ function clip(value: string): string {
}
/** "update_vacay_plan" reads as "update vacay plan"; only shown where nothing better is known. */
export function humanToolName(name: string): string {
function humanToolName(name: string): string {
return name.replace(/_/g, ' ');
}
+1 -1
View File
@@ -37,5 +37,5 @@ export function linkFile(
fileId: number,
target: Pick<EntityData<FileLinks>, 'reservation' | 'assignment' | 'place' | 'budgetItem'>,
): Promise<FileLinkRow> {
return createRow(orm, FileLinks, { file: fileId, ...target });
return createRow(orm, FileLinks, { file: fileId }, target);
}
@@ -89,6 +89,7 @@ const UNNUMBERED = [
'Migration20200101046200_a_user_can_connect_an_ai_provider',
'Migration20200101046210_the_trip_assistant_remembers_its_conversations',
'Migration20200101046220_a_chat_message_can_say_it_came_from_the_assistant',
'Migration20200101046230_an_admin_can_share_a_chatgpt_plan',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -1,8 +1,8 @@
/**
* AiConnectionsRepository and the table under it: one connection per user and
* one for the instance, a save that replaces in place, the version check a
* token refresh writes under, and the checks that keep ChatGPT personal and a
* custom server the admin's.
* token refresh writes under, and the checks that keep a custom server the
* admin's while a ChatGPT sign-in may be either side's.
*/
import { AiConnections } from '../../../../src/db/entities/AiConnections.entity';
import { Users } from '../../../../src/db/entities/Users.entity';
@@ -80,13 +80,15 @@ describe('AiConnectionsRepository', () => {
expect(await repo.findById(id)).toBeNull();
});
it('AICREPO-003: the table refuses a ChatGPT instance connection, a custom user one and a stray base URL', async () => {
it('AICREPO-003: the table refuses a custom user connection and a stray base URL, takes a shared ChatGPT plan', async () => {
const { user } = await makeUser(t);
await expect(repo.save(null, { ...KEY, kind: 'chatgpt' })).rejects.toThrow(/CHECK/);
await expect(repo.save(user.id, { ...KEY, kind: 'custom', baseUrl: 'http://lan' })).rejects.toThrow(/CHECK/);
await expect(repo.save(null, { ...KEY, baseUrl: 'http://lan' })).rejects.toThrow(/CHECK/);
await expect(repo.save(null, { ...KEY, kind: 'custom' })).rejects.toThrow(/CHECK/);
await expect(insertRow(t, AiConnections, { scope: 'user', kind: 'openai', model: 'm' })).rejects.toThrow(/CHECK/);
// An admin may share their own ChatGPT plan with everyone.
await repo.save(null, { ...KEY, kind: 'chatgpt' });
expect(await repo.findInstance()).toMatchObject({ scope: 'instance', user_id: null, kind: 'chatgpt' });
});
it('AICREPO-004: a user deleted takes their connection along', async () => {
@@ -23,6 +23,8 @@ import { GUARDS_METADATA, HTTP_CODE_METADATA, METHOD_METADATA, PATH_METADATA } f
import { describe, expect, it, vi } from 'vitest';
const VIEW = { scope: 'user', kind: 'anthropic', model: 'm', hint: '…1234', status: 'ok', host: 'api.anthropic.com' };
const INSTANCE_VIEW = { ...VIEW, scope: 'instance', kind: 'chatgpt', host: 'chatgpt.com' };
const ADMIN_CONNECTION = { connection: INSTANCE_VIEW, prefill: null };
const user = { id: 7, role: 'user' } as User;
function services() {
@@ -35,16 +37,17 @@ function services() {
removeOwn: vi.fn(async () => undefined),
ackPrivacy: vi.fn(async () => undefined),
ownView: vi.fn(async () => VIEW),
instanceView: vi.fn(async () => INSTANCE_VIEW),
readSettings: vi.fn(async () => ({ policy: 'user' })),
writeSettings: vi.fn(async () => ({ policy: 'user' })),
instanceConnection: vi.fn(async () => ({ connection: null, prefill: null })),
instanceConnection: vi.fn(async () => ADMIN_CONNECTION),
saveInstance: vi.fn(async () => ({ connection: null, prefill: null })),
removeInstance: vi.fn(async () => undefined),
listInstanceModels: vi.fn(async () => []),
},
chatgpt: {
start: vi.fn(async () => ({ flowId: 'f', authorizeUrl: 'https://auth.openai.com', expiresAt: 'x' })),
finish: vi.fn(async () => undefined),
finish: vi.fn(async (): Promise<'user' | 'instance'> => 'user'),
signOut: vi.fn(async () => undefined),
},
openrouter: {
@@ -136,10 +139,14 @@ describe('AiConnectionsController', () => {
await controller.startChatgpt(user);
expect(s.chatgpt.start).toHaveBeenCalledWith(7);
const flowId = '7d3c2a64-0f1e-4c4c-9a39-7d2a7a0c5b11';
expect(
await controller.finishChatgpt(user, { flowId, pastedUrl: 'http://127.0.0.1:1455/auth/callback?code=c' }),
).toBe(VIEW);
expect(s.chatgpt.finish).toHaveBeenCalledWith(7, flowId, 'http://127.0.0.1:1455/auth/callback?code=c');
const pastedUrl = 'http://127.0.0.1:1455/auth/callback?code=c';
expect(await controller.finishChatgpt(user, { flowId, pastedUrl })).toBe(VIEW);
// The service gets the caller with their role, so it can refuse a sign-in for everyone to a non-admin.
expect(s.chatgpt.finish).toHaveBeenCalledWith(user, flowId, pastedUrl);
expect(s.connections.ownView).toHaveBeenCalledWith(7);
// A sign-in an admin started for everyone answers with the instance's connection.
s.chatgpt.finish.mockResolvedValueOnce('instance');
expect(await controller.finishChatgpt(user, { flowId, pastedUrl })).toBe(INSTANCE_VIEW);
expect(await controller.signOutChatgpt(user)).toBe(VIEW);
expect(s.chatgpt.signOut).toHaveBeenCalledWith(7);
});
@@ -162,6 +169,7 @@ describe('AiAdminController', () => {
controller: new AiAdminController(
s.connections as unknown as AiConnectionsService,
s.openrouter as unknown as OpenrouterAuthService,
s.chatgpt as unknown as ChatgptAuthService,
),
};
}
@@ -174,9 +182,17 @@ describe('AiAdminController', () => {
});
expect(Reflect.getMetadata(MANAGED_FORBIDDEN, AiAdminController)?.reason).toBeTruthy();
expect(
['settings', 'saveSettings', 'connection', 'saveConnection', 'removeConnection', 'models', 'startOpenrouter'].map(
(name) => route(AiAdminController, name),
),
[
'settings',
'saveSettings',
'connection',
'saveConnection',
'removeConnection',
'models',
'startOpenrouter',
'startChatgpt',
'signOutChatgpt',
].map((name) => route(AiAdminController, name)),
).toEqual([
{ method: RequestMethod.GET, path: 'settings', httpCode: undefined },
{ method: RequestMethod.PUT, path: 'settings', httpCode: undefined },
@@ -185,6 +201,8 @@ describe('AiAdminController', () => {
{ method: RequestMethod.DELETE, path: 'connection', httpCode: 200 },
{ method: RequestMethod.GET, path: 'models', httpCode: undefined },
{ method: RequestMethod.POST, path: 'connect/openrouter/start', httpCode: 200 },
{ method: RequestMethod.POST, path: 'connect/chatgpt/start', httpCode: 200 },
{ method: RequestMethod.POST, path: 'connect/chatgpt/sign-out', httpCode: 200 },
]);
});
@@ -206,5 +224,10 @@ describe('AiAdminController', () => {
expect(await controller.models(admin)).toEqual({ models: [] });
await controller.startOpenrouter(admin);
expect(s.openrouter.start).toHaveBeenCalledWith(1, 'instance');
// The admin's own ChatGPT plan, shared with everyone: started and signed out for the instance.
await controller.startChatgpt(admin);
expect(s.chatgpt.start).toHaveBeenCalledWith(1, 'instance');
expect(await controller.signOutChatgpt(admin)).toBe(ADMIN_CONNECTION);
expect(s.chatgpt.signOut).toHaveBeenCalledWith(1, 'instance');
});
});
@@ -24,7 +24,7 @@ import { OidcService } from '../../../../src/nest/oidc/oidc.service';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { findRow } from '../../../helpers/factories/rows';
import { readAppSetting } from '../../../helpers/factories/settings';
import { makeAdmin, makeUser } from '../../../helpers/factories/users';
import { makeUser } from '../../../helpers/factories/users';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestModule, type TestModule } from '../../../helpers/test-module';
import { MikroORM } from '@mikro-orm/core';
@@ -99,6 +99,12 @@ async function refusal(promise: Promise<unknown>) {
return { status: (err as DomainError).getStatus(), body: (err as DomainError).toBody() };
}
/** A stored user as the service takes the caller of a finished sign-in: the id and the role. */
async function caller(role: 'user' | 'admin' = 'user'): Promise<{ id: number; role: string }> {
const { user } = await makeUser(orm(), { role });
return { id: user.id, role };
}
/** Starts a sign-in and answers its flow id with the state and nonce the authorize URL carried. */
async function started(userId: number, scope: 'user' | 'instance' = 'user') {
const start = await service().start(userId, scope);
@@ -161,7 +167,7 @@ describe('starting a sign-in', () => {
describe('finishing a sign-in', () => {
it('CGPT-010: exchanges the code, checks the ID token and saves the connection', async () => {
const { user } = await makeUser(orm());
const user = await caller();
const flow = await started(user.id);
const access = accessToken(Math.floor(Date.now() / 1000) + 3600);
safeFetch.mockResolvedValueOnce(
@@ -176,7 +182,7 @@ describe('finishing a sign-in', () => {
verifyIdToken.mockResolvedValueOnce(claims({ nonce: flow.nonce }));
await service().finish(
user.id,
user,
flow.flowId,
callbackUrl({ code: 'code-1', state: flow.state, client_id: 'oaiapp_new_1' }),
);
@@ -207,8 +213,8 @@ describe('finishing a sign-in', () => {
});
it('CGPT-030: an admin shares their plan with everyone; only an admin finishes that sign-in', async () => {
const { user: admin } = await makeAdmin(orm());
const { user: member } = await makeUser(orm());
const admin = await caller('admin');
const member = await caller();
const shared = await started(admin.id, 'instance');
const url = callbackUrl({ code: 'c', state: shared.state, client_id: 'oaiapp_shared' });
// A flow belongs to whoever started it, so a member cannot even take it.
@@ -219,7 +225,10 @@ describe('finishing a sign-in', () => {
);
verifyIdToken.mockResolvedValueOnce(claims({ nonce: shared.nonce }));
expect(await service().finish(admin, shared.flowId, url)).toBe('instance');
expect(await findRow(orm(), AiConnections, { scope: 'instance' })).toMatchObject({ kind: 'chatgpt', user: null });
expect(await findRow(orm(), AiConnections, { scope: 'instance' })).toMatchObject({
kind: 'chatgpt',
user_id: null,
});
expect(await findRow(orm(), AiConnections, { user: admin.id })).toBeNull();
// An admin who is no longer one cannot finish a sign-in for everyone they started before.
@@ -230,7 +239,7 @@ describe('finishing a sign-in', () => {
});
it('CGPT-011: a wrong address is refused before the flow is used, so the right one still works', async () => {
const { user } = await makeUser(orm());
const user = await caller();
const flow = await started(user.id);
const wrong = await Promise.all(
['not a url', 'http://localhost:1455/auth/callback?code=x', 'https://chatgpt.com/'].map((pasted) =>
@@ -251,8 +260,8 @@ describe('finishing a sign-in', () => {
});
it('CGPT-012: another state, an unknown or foreign flow, and a used one read as expired', async () => {
const { user } = await makeUser(orm());
const { user: other } = await makeUser(orm());
const user = await caller();
const other = await caller();
const flow = await started(user.id);
const url = callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_x' });
expect((await refusal(service().finish(other, flow.flowId, url))).status).toBe(410);
@@ -270,7 +279,7 @@ describe('finishing a sign-in', () => {
});
it('CGPT-013: a client id that is not an issued one, named twice, or another than the saved one is refused', async () => {
const { user } = await makeUser(orm());
const user = await caller();
const refused = await Promise.all(
['&client_id=evil', '&client_id=oaiapp_a&client_id=oaiapp_b'].map(async (extra) => {
const flow = await started(user.id);
@@ -285,18 +294,14 @@ describe('finishing a sign-in', () => {
expect(
(
await refusal(
service().finish(
user.id,
flow.flowId,
callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_other' }),
),
service().finish(user, flow.flowId, callbackUrl({ code: 'c', state: flow.state, client_id: 'oaiapp_other' })),
)
).body,
).toMatchObject({ code: 'wrong_url' });
});
it('CGPT-014: a nonce that does not match, another account and a plan without the scope are refused', async () => {
const { user } = await makeUser(orm());
const user = await caller();
const answer = () =>
tokenAnswer(200, { access_token: 'at', refresh_token: 'rt', id_token: 'id', scope: PLAN_SCOPES });
@@ -334,7 +339,7 @@ describe('finishing a sign-in', () => {
});
it('CGPT-015: a refused code reads as expired, a token endpoint that is down as provider_unavailable', async () => {
const { user } = await makeUser(orm());
const user = await caller();
let flow = await started(user.id);
safeFetch.mockResolvedValueOnce(tokenAnswer(400, { error: 'invalid_grant' }));
expect(
@@ -5,7 +5,7 @@
* applies it, the same an MCP write runs.
*/
import { AssistantBookingImportService } from '../../../../src/nest/assistant/assistant-booking-import.service';
import { MAX_BOOKINGS_PER_FILE } from '../../../../src/nest/assistant/assistant-tools.constants';
import { MAX_WRITES_PER_STEP } from '../../../../src/nest/assistant/assistant-tools.constants';
import { BookingImportService } from '../../../../src/nest/booking-import/booking-import.service';
import { DemoService } from '../../../../src/nest/common/demo.service';
import { DomainError } from '../../../../src/nest/common/domain-error';
@@ -42,7 +42,7 @@ async function make(opts: Opts = {}) {
aiAvailable: vi.fn(async () => opts.ai === true),
readsImages: vi.fn(async () => opts.images === true),
preview: vi.fn(async (_files: Express.Multer.File[], _mode: string, _userId: number) => ({
items: Array.from({ length: MAX_BOOKINGS_PER_FILE + 2 }, (_, index) => ({ ...ITEM, title: `Leg ${index}` })),
items: Array.from({ length: MAX_WRITES_PER_STEP + 2 }, (_, index) => ({ ...ITEM, title: `Leg ${index}` })),
warnings: [],
})),
confirm: vi.fn(async () => ({ created: [{ id: 81, title: 'ICE 123', type: 'train', day_id: 31 }] })),
@@ -88,7 +88,7 @@ describe('AssistantBookingImportService', () => {
const items = await plain.service.read(7, file('a.pdf'));
const given = expect.objectContaining({ originalname: 'a.pdf' });
expect(plain.bookingImport.preview).toHaveBeenCalledWith([given], 'no-ai', 7);
expect(items).toHaveLength(MAX_BOOKINGS_PER_FILE);
expect(items).toHaveLength(MAX_WRITES_PER_STEP);
const ai = await make({ ai: true });
await ai.service.read(7, file('a.pdf'));
expect(ai.bookingImport.preview.mock.calls[0][1]).toBe('fallback-on-empty');