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
87 changes: 73 additions & 14 deletions src/portal/__tests__/mcp-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,20 @@ interface TokenRow {
last_used_at: Date | null;
}

// In-memory model for ai_search_log rows (only the columns listMcpTokens reads)
interface SearchRow {
account_id: string;
client: string | null;
ts: Date;
}

let rows: TokenRow[];
let searchLog: SearchRow[];

function memPool() {
return {
query: async (sql: string, params: any[]) => {
// List tokens for an account
if (/SELECT token_hash.*FROM account_api_tokens/is.test(sql)) {
const accountId = params[0];
const found = rows
.filter((r) => r.account_id === accountId)
.map((r) => ({
token_hash: r.token_hash,
label: r.label,
created_at: r.created_at,
last_used_at: r.last_used_at,
}));
return { rows: found };
}
// Delete a specific token for a specific account
// Delete a specific token for a specific account (check first: also matches FROM)
if (/DELETE FROM account_api_tokens WHERE account_id=\$1 AND token_hash=\$2/i.test(sql)) {
const accountId = params[0];
const tokenHash = params[1];
Expand All @@ -48,13 +43,37 @@ function memPool() {
);
return { rows: [], rowCount: before - rows.length };
}
// List tokens for an account, with last_used_at derived from ai_search_log
// (GREATEST(stored, MAX(log.ts where client = COALESCE(label,'Assistente')))).
if (/FROM account_api_tokens/is.test(sql)) {
const accountId = params[0];
const found = rows
.filter((r) => r.account_id === accountId)
.map((r) => {
const client = r.label ?? "Assistente";
const logTimes = searchLog
.filter((s) => s.account_id === r.account_id && s.client === client)
.map((s) => s.ts.getTime());
const candidates = [r.last_used_at, logTimes.length ? new Date(Math.max(...logTimes)) : null]
.filter((d): d is Date => d != null)
.map((d) => d.getTime());
return {
token_hash: r.token_hash,
label: r.label,
created_at: r.created_at,
last_used_at: candidates.length ? new Date(Math.max(...candidates)) : null,
};
});
return { rows: found };
}
return { rows: [], rowCount: 0 };
},
};
}

beforeEach(() => {
rows = [];
searchLog = [];
__setPoolForTest(memPool() as never);
});
afterEach(() => __setPoolForTest(null));
Expand All @@ -69,6 +88,10 @@ function seed(accountId: string, hash: string, label: string | null = null) {
});
}

function seedSearch(accountId: string, client: string | null, ts: string) {
searchLog.push({ account_id: accountId, client, ts: new Date(ts) });
}

// --- listMcpTokens ---

test("listMcpTokens returns only tokens belonging to the given account", async () => {
Expand Down Expand Up @@ -101,6 +124,42 @@ test("listMcpTokens exposes id, name, created_at, last_used_at but NOT secrets",
assert.ok(!("token" in t));
});

test("listMcpTokens derives last_used_at from ai_search_log by matching client=label", async () => {
// Regression: account_api_tokens.last_used_at is never written, so without the
// ai_search_log join the UI shows "nunca usado" even for a token that searched.
seed("account:a", "hash-a1", "Outra IA");
seedSearch("account:a", "Outra IA", "2026-06-17T21:23:04Z");
seedSearch("account:a", "Outra IA", "2026-06-17T21:23:17Z"); // newer
seedSearch("account:a", "Consultar", "2026-06-18T00:00:00Z"); // different client, ignored

const [t] = await listMcpTokens("account:a");
assert.equal(t.last_used_at, new Date("2026-06-17T21:23:17Z").toISOString());
});

test("listMcpTokens uses the 'Assistente' fallback client for unlabeled tokens", async () => {
seed("account:a", "hash-a1", null);
seedSearch("account:a", "Assistente", "2026-06-17T10:00:00Z");

const [t] = await listMcpTokens("account:a");
assert.equal(t.last_used_at, new Date("2026-06-17T10:00:00Z").toISOString());
});

test("listMcpTokens keeps last_used_at null when the token never searched", async () => {
seed("account:a", "hash-a1", "Outra IA");
seedSearch("account:a", "Claude.ai", "2026-06-17T10:00:00Z"); // a DIFFERENT client searched

const [t] = await listMcpTokens("account:a");
assert.equal(t.last_used_at, null);
});

test("listMcpTokens does not cross accounts when deriving last_used_at", async () => {
seed("account:a", "hash-a1", "Outra IA");
seedSearch("account:b", "Outra IA", "2026-06-17T10:00:00Z"); // same label, other account

const [t] = await listMcpTokens("account:a");
assert.equal(t.last_used_at, null);
});

// --- revokeMcpToken ---

test("revokeMcpToken removes the token and returns true", async () => {
Expand Down
26 changes: 21 additions & 5 deletions src/portal/mcp-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ export interface McpTokenSummary {
last_used_at: string | null;
}

/** List all MCP tokens for a given account. Returns [] when none exist. */
/** List all MCP tokens for a given account. Returns [] when none exist.
* `last_used_at` is DERIVED from ai_search_log (MAX(ts) where the log's client
* equals this token's label), not read from the column: the column is never
* written, so without this the UI would always show "nunca usado". The match
* mirrors how index.ts stamps ai_search_log.client at request time — the token
* label, or the generic "Assistente" fallback for unlabeled tokens. GREATEST
* keeps the stored value if a future write path ever populates the column
* (GREATEST ignores NULLs, so a NULL column collapses to the log-derived ts). */
export async function listMcpTokens(accountId: string): Promise<McpTokenSummary[]> {
const p = getPool();
const { rows } = await p.query<{
Expand All @@ -22,10 +29,19 @@ export async function listMcpTokens(accountId: string): Promise<McpTokenSummary[
created_at: Date;
last_used_at: Date | null;
}>(
`SELECT token_hash, label, created_at, last_used_at
FROM account_api_tokens
WHERE account_id=$1
ORDER BY created_at DESC`,
`SELECT t.token_hash,
t.label,
t.created_at,
GREATEST(t.last_used_at, l.last_search) AS last_used_at
FROM account_api_tokens t
LEFT JOIN LATERAL (
SELECT max(s.ts) AS last_search
FROM ai_search_log s
WHERE s.account_id = t.account_id
AND s.client = COALESCE(t.label, 'Assistente')
) l ON true
WHERE t.account_id=$1
ORDER BY t.created_at DESC`,
[accountId],
);
return rows.map((r) => ({
Expand Down
Loading