The owner's asymmetry find (interlude over the zero-keys wave): the models-unconfigured case guards BEFORE send — no request, no chat created, setup opens — but the key cases only reacted to the 503 after POST /api/chats had already created the record, leaving orphan error-shell chats in the sidebar. The guards now twin the models mechanism on BOTH creation paths (home start(), /chat/new send()): - Zero keys (useZeroKeys reuse): no provider key present -> skip creation and send entirely, open SetupDialog (stage 1 first-key screen). - Strong-provider key missing (owner-approved extension): keys exist generally but the strong slot's pinned provider has none — the original deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx: slots.strong resolved pin + providers[] (present-key only, the catalog's own federation surface), false when unconfigured (the models guard owns that case) or catalog not loaded (the server 503 gate remains the belt). useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND 'models' signals (a deleted key or a re-pinned slot flips it live). Both guards run after the configured check, same skip-everything-and-open-setup treatment; 'force' (setup's onDone replay) bypasses both — completing setup implies a keyed provider. /chat/[id] sessions keep the 503 revert-draft interception unchanged: those chats legitimately exist. Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or none), never fires with the pin's key present (incl. the keyPresent=false belt), false on unset pin / unloaded catalog. Normal-path pins ride the existing startDraftSession contract suite. Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong: both guards on both pages (setup opens stage-1 / stage-2 respectively, draft preserved, 154 chats unchanged, zero sidebar entries), normal path with a keyed strong provider still creates and lands the prompt redirect. 929/929, tsc/lint/build green.
157 lines
6.5 KiB
TypeScript
157 lines
6.5 KiB
TypeScript
/**
|
|
* The zero-keys wave's pure seams (owner-locked over Platform's 66dbea9
|
|
* key-presence gate, 2026-09-25): the provider-key truth (hasProviderKey
|
|
* — tool keys don't count, they unlock no models), the pill's SSR shape,
|
|
* and the SetupDialog's self-contained first-key screen (the dialog STAYS
|
|
* MOUNTED after a save; these pins hold the screen's structure).
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { hasProviderKey, strongKeyless, ZeroKeysPill } from "../components/setup-cta.tsx";
|
|
import type { ModelCatalogResponse } from "../src/core/index.ts";
|
|
import { SetupKeysBody } from "../components/setup-dialog.tsx";
|
|
import { DICTATION_KEYLESS, DEEPGRAM_KEY_ID } from "../components/dictation.tsx";
|
|
import type { AuthKeyStatus } from "../src/core/auth-keys.ts";
|
|
import type { ProviderKeyInfo } from "../components/settings/keys-section.tsx";
|
|
|
|
const status = (id: string, present: boolean, source: AuthKeyStatus["source"] = present ? "file" : "none"): AuthKeyStatus => ({
|
|
id,
|
|
info: null,
|
|
present,
|
|
masked: present ? "…ab12 (64 chars)" : null,
|
|
source,
|
|
shadowedByEnv: false,
|
|
});
|
|
|
|
const PROVIDERS: ProviderKeyInfo[] = [
|
|
{ id: "openrouter", envVar: "OPENROUTER_API_KEY", label: "OpenRouter" },
|
|
{ id: "anthropic", envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
|
|
];
|
|
|
|
describe("hasProviderKey — the zero-keys pill's truth", () => {
|
|
it("a present provider key (env or file) is a key", () => {
|
|
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", true, "env")], PROVIDERS), true);
|
|
assert.equal(hasProviderKey([status("ANTHROPIC_API_KEY", true, "file")], PROVIDERS), true);
|
|
});
|
|
|
|
it("a TOOL key alone does not count — Deepgram unlocks no models", () => {
|
|
// Deepgram is not in the providers roster: it must never suppress the pill.
|
|
assert.equal(hasProviderKey([status("DEEPGRAM_API_KEY", true)], PROVIDERS), false);
|
|
});
|
|
|
|
it("absent provider keys = zero keys (the pill shows)", () => {
|
|
assert.equal(hasProviderKey([status("OPENROUTER_API_KEY", false)], PROVIDERS), false);
|
|
assert.equal(hasProviderKey([], PROVIDERS), false);
|
|
});
|
|
});
|
|
|
|
describe("ZeroKeysPill — the composer-hoisted notice", () => {
|
|
it("the pill takes the CTA's chrome with its own testid and copy", () => {
|
|
const html = renderToStaticMarkup(createElement(ZeroKeysPill, { onOpen: () => {} }));
|
|
assert.ok(html.includes('data-testid="setup-cta-keys"'));
|
|
assert.ok(html.includes("Add a provider key"));
|
|
// the models CTA's testid must not appear on the keys pill
|
|
assert.ok(!html.includes('data-testid="setup-cta"'));
|
|
});
|
|
});
|
|
|
|
describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
|
|
const base = {
|
|
value: "",
|
|
saving: false,
|
|
error: null,
|
|
menuOpen: false,
|
|
onToggleMenu: () => {},
|
|
onPick: () => {},
|
|
onValue: () => {},
|
|
onSave: () => {},
|
|
menuAnchor: { current: null },
|
|
};
|
|
|
|
it("before a pick: the provider trigger, no key input yet", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
|
|
);
|
|
assert.match(html, /First, add a provider key\./);
|
|
assert.ok(html.includes("The model pickers list only what your keys unlock"));
|
|
assert.ok(html.includes('data-testid="setup-key-provider"'));
|
|
assert.ok(html.includes("Pick a provider…"));
|
|
assert.ok(!html.includes('data-testid="setup-key-input"'), "no paste field before a provider is picked");
|
|
});
|
|
|
|
it("after a pick: the paste input + Save join", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: PROVIDERS[0]! }),
|
|
);
|
|
assert.ok(html.includes('data-testid="setup-key-input"'));
|
|
assert.ok(html.includes('data-testid="setup-key-save"'));
|
|
assert.ok(html.includes("Save key"));
|
|
assert.ok(html.includes("OpenRouter"), "the chosen provider's label rides the trigger");
|
|
});
|
|
|
|
it("a shape-gate 400 keeps its line inline — no Settings round-trip chrome", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(SetupKeysBody, {
|
|
...base,
|
|
providers: PROVIDERS,
|
|
provider: PROVIDERS[0]!!,
|
|
error: "Expected 20+ characters",
|
|
}),
|
|
);
|
|
assert.ok(html.includes('data-testid="setup-key-error"'));
|
|
assert.ok(html.includes("Expected 20+ characters"));
|
|
// the old "Open Keys settings" round-trip died with the self-contained screen
|
|
assert.ok(!html.includes("setup-open-keys"));
|
|
});
|
|
});
|
|
|
|
describe("the dictation keyless seam (owner dictation trio)", () => {
|
|
it("the client-authored short line — not the route's dev-speak", () => {
|
|
assert.equal(DICTATION_KEYLESS, "Dictation needs a Deepgram key");
|
|
});
|
|
|
|
it("the Deepgram row id matches the keys store (KeysSection openRow)", () => {
|
|
assert.equal(DEEPGRAM_KEY_ID, "DEEPGRAM_API_KEY");
|
|
});
|
|
});
|
|
/** A minimal catalog double — only the two halves the strong-keyless guard
|
|
* reads: the strong pin and the present-key providers list. */
|
|
const catalog = (strongProvider: string | null, ...keyedProviders: string[]): ModelCatalogResponse => ({
|
|
models: [],
|
|
defaults: null,
|
|
rolePins: { researcher: null, demoBuilder: null, visualMaker: null, extractor: null, auditor: null },
|
|
slots: {
|
|
strong: strongProvider
|
|
? { model: { provider: strongProvider, id: `${strongProvider}/model` }, thinkingLevel: "low" }
|
|
: null,
|
|
cheap: null,
|
|
vision: null,
|
|
},
|
|
providers: keyedProviders.map((id) => ({ id, envVar: `${id.toUpperCase()}_API_KEY`, keyPresent: true })),
|
|
});
|
|
|
|
describe("strongKeyless — the pre-send guard's truth (owner interlude, no-orphan-chats)", () => {
|
|
it("strong pinned to a provider whose key is gone: the guard FIRES", () => {
|
|
// keys exist generally (anthropic), but the strong slot pins openrouter
|
|
assert.equal(strongKeyless(catalog("openrouter", "anthropic")), true);
|
|
// no keys at all with a strong pin: also keyless (guard case 1 fires too)
|
|
assert.equal(strongKeyless(catalog("openrouter")), true);
|
|
});
|
|
|
|
it("strong provider's key present: the guard does NOT fire — the normal send path stays open", () => {
|
|
assert.equal(strongKeyless(catalog("openrouter", "openrouter", "anthropic")), false);
|
|
// belt check: a provider row never counts when keyPresent is false
|
|
const lying = catalog("openrouter", "openrouter", "anthropic");
|
|
lying.providers[0]!.keyPresent = false;
|
|
assert.equal(strongKeyless(lying), true);
|
|
});
|
|
|
|
it("no strong pin (the models guard owns that case) or catalog not loaded: false — the server gate remains the belt", () => {
|
|
assert.equal(strongKeyless(catalog(null, "openrouter")), false);
|
|
assert.equal(strongKeyless(undefined), false);
|
|
});
|
|
});
|