diff --git a/.env.example b/.env.example index ff00b57..34776b7 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,11 @@ DNLA_API_BASE=https://backend.dnla.com DNLA_AREA=ESK # Shared secret for DNLA's completion webhook: /api/dnla/webhook?secret= DNLA_WEBHOOK_SECRET= +# TEMPORARY TEST ONLY: comma/space-separated pre-issued TANs. The server +# reserves each TAN once and opens https://next.dnla.com/?tan= without +# calling tan/create. Store the real list only in .env.local / Secret Manager. +DNLA_TEST_TANS= +DNLA_TEST_START_BASE=https://next.dnla.com/ # Result-scale calibration — CONFIRM WITH DNLA (max raw value + polarity). DNLA_SCALE_MAX=9 DNLA_SCALE_HIGHER_IS_BETTER=true diff --git a/.env.local.example b/.env.local.example index cb84671..44dc415 100644 --- a/.env.local.example +++ b/.env.local.example @@ -21,6 +21,9 @@ DNLA_AREA="ESK" # Shared secret that authenticates DNLA's completion webhook. Register the # webhook URL as https:///api/dnla/webhook?secret= DNLA_WEBHOOK_SECRET="" +# TEMPORARY TEST ONLY. Keep the real pre-issued TAN list out of git. +DNLA_TEST_TANS="" +DNLA_TEST_START_BASE="https://next.dnla.com/" # Result-scale calibration — CONFIRM WITH DNLA. Max raw `values` and polarity # (set HIGHER_IS_BETTER=false if 1 = best). Wrong values only mis-scale, never crash. DNLA_SCALE_MAX="9" @@ -34,4 +37,4 @@ DNLA_SCALE_HIGHER_IS_BETTER="true" # To upgrade to Google Cloud Chirp 3 HD voices, enable the Cloud Text-to-Speech # API and set a key here; leave empty to keep the default Gemini voice. GOOGLE_TTS_API_KEY="" -GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" \ No newline at end of file +GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda" diff --git a/app/api/dnla/start/route.ts b/app/api/dnla/start/route.ts index ad03c3a..4cf65b2 100644 --- a/app/api/dnla/start/route.ts +++ b/app/api/dnla/start/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getPrincipal, unauthorized } from "@/lib/server-auth"; import { enforceRateLimit } from "@/lib/rate-limit"; import { createTan, isDnlaConfigured, DnlaError } from "@/lib/dnla-client"; -import { createDnlaSession } from "@/lib/dnla-store"; +import { claimPreIssuedDnlaSession, createDnlaSession } from "@/lib/dnla-store"; +import { buildDnlaTestStartUrl, getDnlaTestTans } from "@/lib/dnla-test-tans"; import { logger } from "@/lib/logger"; export const runtime = "nodejs"; @@ -10,9 +11,10 @@ export const runtime = "nodejs"; /** * Start a DNLA assessment for the signed-in candidate. * - * Server-side: creates a DNLA TAN (api_key never leaves the server), records the - * TAN↔candidate mapping in Firestore, and returns the DNLA-hosted `start_url` - * the browser should open. Completion is delivered later via /api/dnla/webhook. + * Server-side: either claims one configured pre-issued test TAN or creates a new + * TAN through the partner API. It records the TAN↔candidate mapping in Firestore + * and returns the DNLA-hosted URL the browser should open. Completion is + * delivered later via /api/dnla/webhook. */ export async function POST(req: NextRequest) { const principal = await getPrincipal(req); @@ -26,13 +28,6 @@ export async function POST(req: NextRequest) { }); if (limited) return limited; - if (!isDnlaConfigured()) { - return NextResponse.json( - { ok: false, error: "DNLA is not configured on this deployment yet." }, - { status: 503 } - ); - } - let body: any = {}; try { body = await req.json(); @@ -44,7 +39,43 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "candidateId is required." }, { status: 400 }); } + const testTans = getDnlaTestTans(); + if (testTans.length === 0 && !isDnlaConfigured()) { + return NextResponse.json( + { ok: false, error: "DNLA is not configured on this deployment yet." }, + { status: 503 } + ); + } + try { + if (testTans.length > 0) { + const session = await claimPreIssuedDnlaSession({ + candidates: testTans.map((tan) => ({ tan, startUrl: buildDnlaTestStartUrl(tan) })), + ownerUid: principal.uid, + candidateId, + }); + if (!session) { + return NextResponse.json( + { + ok: false, + error: "All temporary DNLA test TANs have already been reserved.", + }, + { status: 409 } + ); + } + + logger.info("[dnla/start] pre-issued test TAN reserved", { + uid: principal.uid, + candidateId, + }); + return NextResponse.json({ + ok: true, + startUrl: session.startUrl, + tan: session.tan, + mode: "pre-issued-test", + }); + } + const created = await createTan({ email: body?.email ? String(body.email).slice(0, 200) : principal.email, firstname: body?.firstname ? String(body.firstname).slice(0, 120) : undefined, @@ -63,6 +94,7 @@ export async function POST(req: NextRequest) { ok: true, startUrl: created.tan.start_url, tan: created.tan.tan_nummer, + mode: "live", }); } catch (e: any) { const status = e instanceof DnlaError ? e.status : 502; diff --git a/app/api/dnla/status/route.ts b/app/api/dnla/status/route.ts index ee855e2..e4d4306 100644 --- a/app/api/dnla/status/route.ts +++ b/app/api/dnla/status/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { getPrincipal, unauthorized } from "@/lib/server-auth"; import { getLatestDnlaForCandidate } from "@/lib/dnla-store"; +import { isDnlaConfigured } from "@/lib/dnla-client"; +import { isDnlaTestModeConfigured } from "@/lib/dnla-test-tans"; export const runtime = "nodejs"; @@ -19,10 +21,21 @@ export async function GET(req: NextRequest) { } const session = await getLatestDnlaForCandidate(principal.uid, candidateId); - if (!session) return NextResponse.json({ ok: true, status: "none" }); + const testMode = isDnlaTestModeConfigured(); + const available = testMode || isDnlaConfigured(); + if (!session) { + return NextResponse.json({ + ok: true, + status: "none", + available, + mode: testMode ? "pre-issued-test" : "live", + }); + } return NextResponse.json({ ok: true, + available: true, + mode: session.source === "pre-issued-test" ? "pre-issued-test" : "live", status: session.status, // "pending" | "complete" | "error" | "none" startUrl: session.startUrl, finishedAt: session.finishedAt ?? null, diff --git a/app/student/[id]/dnla/dnla-client.tsx b/app/student/[id]/dnla/dnla-client.tsx index 8569998..23f702b 100644 --- a/app/student/[id]/dnla/dnla-client.tsx +++ b/app/student/[id]/dnla/dnla-client.tsx @@ -159,7 +159,8 @@ function DnlaLivePanel({ onStart: () => void; onOpen: () => void; }) { - const { phase, error, starting, startUrl } = live; + const { phase, error, starting, startUrl, mode } = live; + const isTestMode = mode === "pre-issued-test"; if (phase === "loading" || phase === "complete") return null; if (phase === "pending") { @@ -221,14 +222,22 @@ function DnlaLivePanel({
- DNLA behavioural assessment + + {isTestMode ? "Temporary DNLA test flow" : "DNLA behavioural assessment"} + - {phase === "not-configured" ? "Assessment not available yet" : "Start your DNLA assessment"} + {phase === "not-configured" + ? "Assessment not available yet" + : isTestMode + ? "Start with a pre-issued test TAN" + : "Start your DNLA assessment"}

{phase === "not-configured" ? "The DNLA provider isn't configured on this deployment yet. Sample scores are shown below in the meantime." - : "DNLA is a licensed psychometric assessment (Germany). Starting opens the questionnaire in a new tab; your competency profile is scored and shown here when you finish."} + : isTestMode + ? "This branch reserves one unused test TAN and opens DNLA's login screen in a new tab. Finish the questionnaire there; TalEdge will keep checking for the provider result." + : "DNLA is a licensed psychometric assessment (Germany). Starting opens the questionnaire in a new tab; your competency profile is scored and shown here when you finish."}

{phase !== "not-configured" && ( @@ -239,7 +248,7 @@ function DnlaLivePanel({ onClick={onStart} disabled={starting} > - {starting ? "Starting…" : "Start assessment"} + {starting ? "Starting…" : isTestMode ? "Start test assessment" : "Start assessment"} )}
@@ -257,7 +266,8 @@ export default function DnlaClient({ student }: { student: Student }) { // activated (NEXT_PUBLIC_DNLA_ENABLED=true), show ONLY the clearly-labelled // sample profile and hide the "Start assessment" CTA — otherwise a candidate // clicks Start and dead-ends on a 503 (reads as "DNLA is broken"). - const dnlaEnabled = process.env.NEXT_PUBLIC_DNLA_ENABLED === "true"; + const dnlaEnabled = + process.env.NEXT_PUBLIC_DNLA_ENABLED === "true" || live.available; // Shared by both tracks; keep navigation within the current namespace // (/exam for competitive-exam aspirants, /student for placement candidates). @@ -423,6 +433,8 @@ export default function DnlaClient({ student }: { student: Student }) { Assessment error ) : live.phase === "loading" ? ( Checking status… + ) : live.mode === "pre-issued-test" && live.available ? ( + Test TAN available ) : ( Sample data · provider pending )} diff --git a/hooks/useDnlaLive.ts b/hooks/useDnlaLive.ts index 0b858e4..7052c79 100644 --- a/hooks/useDnlaLive.ts +++ b/hooks/useDnlaLive.ts @@ -62,6 +62,8 @@ export function useDnlaLive(candidateId: string) { const [startUrl, setStartUrl] = useState(null); const [error, setError] = useState(""); const [starting, setStarting] = useState(false); + const [available, setAvailable] = useState(false); + const [mode, setMode] = useState<"live" | "pre-issued-test">("live"); const pollRef = useRef | null>(null); const aliveRef = useRef(true); @@ -75,6 +77,8 @@ export function useDnlaLive(candidateId: string) { const applyStatus = useCallback((d: any) => { if (!aliveRef.current) return; const status = String(d?.status || "none"); + setAvailable(d?.available === true); + setMode(d?.mode === "pre-issued-test" ? "pre-issued-test" : "live"); setStartUrl(typeof d?.startUrl === "string" ? d.startUrl : null); if (status === "complete") { setData({ @@ -165,6 +169,7 @@ export function useDnlaLive(candidateId: string) { const d = await r.json().catch(() => ({})); if (r.status === 503) { // Provider not configured on this deployment. + setAvailable(false); setPhase("not-configured"); return null; } @@ -174,6 +179,8 @@ export function useDnlaLive(candidateId: string) { return null; } setStartUrl(d.startUrl); + setAvailable(true); + setMode(d?.mode === "pre-issued-test" ? "pre-issued-test" : "live"); setPhase("pending"); return d.startUrl as string; } catch (e: any) { @@ -187,7 +194,7 @@ export function useDnlaLive(candidateId: string) { [candidateId] ); - return { phase, data, startUrl, error, starting, start, refresh }; + return { phase, data, startUrl, error, starting, available, mode, start, refresh }; } export type DnlaLive = ReturnType; diff --git a/lib/dnla-store.ts b/lib/dnla-store.ts index 2447c6d..1fda542 100644 --- a/lib/dnla-store.ts +++ b/lib/dnla-store.ts @@ -31,6 +31,8 @@ export interface DnlaSession { status: DnlaSessionStatus; /** DNLA-hosted questionnaire URL the candidate is sent to. */ startUrl: string; + /** Whether the TAN was created live or claimed from the temporary test pool. */ + source?: "created" | "pre-issued-test"; /** DNLA internal numeric session id — learned from the completion webhook. */ resultId?: string | null; /** Normalized, axis-mapped scores once complete (feeds the Fit Score). */ @@ -50,6 +52,31 @@ const FILE = path.join(DIR, "dnla.json"); const useFirestore = () => isAdminConfigured && !!adminDb; +function newSession(params: { + tan: string; + ownerUid: string; + candidateId: string; + startUrl: string; + source?: DnlaSession["source"]; +}): DnlaSession { + const now = Date.now(); + return { + tan: params.tan, + ownerUid: params.ownerUid, + candidateId: params.candidateId, + status: "pending", + startUrl: params.startUrl, + source: params.source ?? "created", + resultId: null, + normalized: null, + error: null, + createdAt: now, + updatedAt: now, + finishedAt: null, + expiresAt: now + TTL_MS, + }; +} + /* ----------------------------- file fallback ----------------------------- */ function loadAll(): Record { try { @@ -71,21 +98,7 @@ export async function createDnlaSession(params: { candidateId: string; startUrl: string; }): Promise { - const now = Date.now(); - const session: DnlaSession = { - tan: params.tan, - ownerUid: params.ownerUid, - candidateId: params.candidateId, - status: "pending", - startUrl: params.startUrl, - resultId: null, - normalized: null, - error: null, - createdAt: now, - updatedAt: now, - finishedAt: null, - expiresAt: now + TTL_MS, - }; + const session = newSession(params); if (useFirestore()) { try { await adminDb!.collection(COLLECTION).doc(params.tan).set(session); @@ -100,6 +113,80 @@ export async function createDnlaSession(params: { return session; } +/** + * Atomically reserve one pre-issued TAN for a candidate. A pending reservation + * is idempotently returned to the same candidate; a TAN recorded for anyone + * else (or already completed/errored) is never reused. + */ +export async function claimPreIssuedDnlaSession(params: { + candidates: Array<{ tan: string; startUrl: string }>; + ownerUid: string; + candidateId: string; +}): Promise { + const allowed = new Set(params.candidates.map((candidate) => candidate.tan)); + + if (useFirestore()) { + try { + return await adminDb!.runTransaction(async (tx) => { + const refs = params.candidates.map((candidate) => + adminDb!.collection(COLLECTION).doc(candidate.tan) + ); + const snapshots = await Promise.all(refs.map((ref) => tx.get(ref))); + + const existing = snapshots + .filter((snapshot) => snapshot.exists) + .map((snapshot) => snapshot.data() as DnlaSession) + .find( + (session) => + session.status === "pending" && + session.ownerUid === params.ownerUid && + session.candidateId === params.candidateId && + allowed.has(session.tan) + ); + if (existing) return existing; + + const availableIndex = snapshots.findIndex((snapshot) => !snapshot.exists); + if (availableIndex < 0) return null; + + const session = newSession({ + ...params.candidates[availableIndex], + ownerUid: params.ownerUid, + candidateId: params.candidateId, + source: "pre-issued-test", + }); + tx.set(refs[availableIndex], session); + return session; + }); + } catch (e) { + logger.error("[dnla-store] Firestore test TAN claim failed", { err: String(e) }); + throw e; + } + } + + const all = loadAll(); + const pending = Object.values(all).find( + (session) => + session.status === "pending" && + session.ownerUid === params.ownerUid && + session.candidateId === params.candidateId && + allowed.has(session.tan) + ); + if (pending) return pending; + + const available = params.candidates.find((candidate) => !all[candidate.tan]); + if (!available) return null; + + const session = newSession({ + ...available, + ownerUid: params.ownerUid, + candidateId: params.candidateId, + source: "pre-issued-test", + }); + all[available.tan] = session; + saveAll(all); + return session; +} + export async function getDnlaSessionByTan(tan: string): Promise { if (useFirestore()) { try { diff --git a/lib/dnla-test-tans.ts b/lib/dnla-test-tans.ts new file mode 100644 index 0000000..5d3f870 --- /dev/null +++ b/lib/dnla-test-tans.ts @@ -0,0 +1,52 @@ +/** + * Temporary pre-issued TAN support for DNLA integration testing. + * + * TANs are participant credentials, so they must come from a server-only + * environment variable and must never be committed or sent to the client as a + * pool. The selected TAN is necessarily present in DNLA's questionnaire URL. + */ + +const DEFAULT_START_BASE = "https://next.dnla.com/"; +const TAN_PATTERN = /^[A-Za-z0-9]{4,}-(?:ESK|AZS)-[A-Za-z0-9]{4,}-[A-Za-z0-9]{4,}$/; + +export function parseDnlaTestTans(raw: string | undefined): string[] { + if (!raw) return []; + + const unique = new Set(); + for (const value of raw.split(/[\s,;]+/)) { + const tan = value.trim(); + if (TAN_PATTERN.test(tan)) unique.add(tan); + } + return [...unique]; +} + +export function getDnlaTestTans(): string[] { + return parseDnlaTestTans(process.env.DNLA_TEST_TANS); +} + +export function isDnlaTestModeConfigured(): boolean { + return getDnlaTestTans().length > 0; +} + +/** + * Build the current DNLA login URL for an existing TAN. + * + * DNLA's current frontend consumes `tan` on `/`; `/start?tan=...` skips the + * login/bootstrap screen and remains on a spinner because `/start` expects an + * authenticated DNLA cookie. Force the root path for the official host even if + * an old `/start` URL was copied into configuration. + */ +export function buildDnlaTestStartUrl( + tan: string, + configuredBase = process.env.DNLA_TEST_START_BASE +): string { + if (!TAN_PATTERN.test(tan)) throw new Error("Invalid DNLA test TAN format."); + + const url = new URL(configuredBase?.trim() || DEFAULT_START_BASE); + if (url.protocol !== "https:") throw new Error("DNLA test start URL must use HTTPS."); + if (url.hostname === "next.dnla.com") url.pathname = "/"; + url.hash = ""; + url.search = ""; + url.searchParams.set("tan", tan); + return url.toString(); +} diff --git a/package.json b/package.json index 2aacccb..28edfe0 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "check:sync": "node scripts/check-sync.mjs", "loadtest": "k6 run loadtest/platform.test.js", "loadtest:smoke": "SCENARIO=smoke k6 run loadtest/platform.test.js", - "loadtest:stress": "SCENARIO=stress k6 run loadtest/platform.test.js" + "loadtest:stress": "SCENARIO=stress k6 run loadtest/platform.test.js", + "test:dnla": "tsx --test tests/dnla-test-tans.test.ts" }, "dependencies": { "@gsap/react": "^2.1.2", diff --git a/tests/dnla-test-tans.test.ts b/tests/dnla-test-tans.test.ts new file mode 100644 index 0000000..65c3a73 --- /dev/null +++ b/tests/dnla-test-tans.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildDnlaTestStartUrl, parseDnlaTestTans } from "../lib/dnla-test-tans"; + +test("parses, validates, and deduplicates the server-side TAN pool", () => { + assert.deepEqual( + parseDnlaTestTans( + "alpha1-ESK-bravo2-charlie3, invalid alpha1-ESK-bravo2-charlie3\nalpha2-AZS-bravo3-charlie4" + ), + ["alpha1-ESK-bravo2-charlie3", "alpha2-AZS-bravo3-charlie4"] + ); +}); + +test("builds the DNLA login URL at the root, not the spinner-only /start route", () => { + assert.equal( + buildDnlaTestStartUrl( + "alpha1-ESK-bravo2-charlie3", + "https://next.dnla.com/start?old=value" + ), + "https://next.dnla.com/?tan=alpha1-ESK-bravo2-charlie3" + ); +}); + +test("rejects insecure start hosts and malformed TANs", () => { + assert.throws( + () => buildDnlaTestStartUrl("alpha1-ESK-bravo2-charlie3", "http://next.dnla.com/"), + /HTTPS/ + ); + assert.throws(() => buildDnlaTestStartUrl("not-a-tan"), /Invalid/); +});