From 1ce9feb87edc59d555cd1ef65cd99d5b5b0451e9 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Sun, 17 May 2026 20:59:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20GET=20/v1/whoami=20=E2=80=94=20ide?= =?UTF-8?q?ntity=20probe=20for=20SDK=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lightweight diagnostic endpoint. SDK clients (or curious operators) can hit /v1/whoami and learn what their authKey resolves to without firing a domain call and inferring from 403/200 patterns. Response shape: { "authenticated": bool, "isMaster": bool, "companyId": int | null } Three documented states: - master key: {true, true, null} - scoped key: {true, false, } - unknown key: {false, false, null} (200, not 403) The 200-vs-403 split is deliberate: a missing authKey header returns 403 (the client never sent credentials), but a present header with an unrecognized key returns 200 with `authenticated: false` so the client can distinguish "network plumbing wrong" from "credential wrong" without parsing error messages. Tests: 4 cases covering the auth contract (403 without header), route mounting, the DB-unreachable fallback path (which under test env's broken DB happens to produce the documented unknown- key shape — so we test the public contract there), and response shape invariants. OpenAPI spec gets the path + response schema. Suite: 31 files / 227 passing + 4 integration skipped (was 30 / 223 + 4 skipped). Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/openapi.js | 30 ++++++++++ app/controllers/whoamicontroller.js | 73 ++++++++++++++++++++++ app/routers/router.js | 6 ++ tests/api/whoami.test.js | 93 +++++++++++++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 app/controllers/whoamicontroller.js create mode 100644 tests/api/whoami.test.js diff --git a/app/config/openapi.js b/app/config/openapi.js index ab3844f..5c91b40 100644 --- a/app/config/openapi.js +++ b/app/config/openapi.js @@ -314,6 +314,36 @@ const spec = { }, }, }, + '/v1/whoami': { + get: { + summary: 'Identity probe — what does the calling authKey resolve to?', + description: 'Returns whether the supplied authKey is recognized, ' + + 'whether it is a master key, and which company it is scoped to. ' + + 'Useful for SDK clients confirming wiring without firing a ' + + 'domain endpoint and inferring from a 403/200.', + security: [{ authKey: [] }], + responses: { + 200: { + description: 'OK — body indicates whether the key is recognized', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + authenticated: { type: 'boolean' }, + isMaster: { type: 'boolean' }, + companyId: { type: 'integer', nullable: true }, + }, + required: ['authenticated', 'isMaster', 'companyId'], + }, + }, + }, + }, + 403: { description: 'authKey header missing entirely' }, + 500: { description: 'Server error (DB lookup failed)' }, + }, + }, + }, '/v1/customer/{id}': { get: { summary: 'Get one customer by id', diff --git a/app/controllers/whoamicontroller.js b/app/controllers/whoamicontroller.js new file mode 100644 index 0000000..50f649d --- /dev/null +++ b/app/controllers/whoamicontroller.js @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +"use strict"; + +/** + * GET /v1/whoami — return what the calling authKey resolves to. + * + * Useful for SDK clients to confirm wiring (is my key recognized? + * is it a master key? which company does it belong to?) without + * having to call a domain endpoint and infer from a 403/200 result. + * + * Response shape: + * { + * "authenticated": true|false, + * "isMaster": true|false, + * "companyId": |null + * } + * + * A missing authKey header returns 403 like every other v1 + * endpoint. An unknown authKey returns 200 with + * { authenticated: false, isMaster: false, companyId: null } + * — we deliberately distinguish "header missing" from "header + * present but key not in our records" so a client can tell + * whether the network plumbing is wrong vs. the credential. + */ + +const log = require('../config/logger.js'); +const auth = require('../middleware/auth.js'); + +exports.whoami = async (req, res) => { + const authKey = req.get('authKey'); + if (!authKey) { + return res.status(403).json({ message: "Authorization key not sent." }); + } + + let isMaster; + try { + isMaster = await auth.isMaster(authKey); + } catch (error) { + log.error({ err: error }, 'whoami: isMaster failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + if (isMaster) { + return res.status(200).json({ + authenticated: true, + isMaster: true, + companyId: null, + }); + } + + let companyId; + try { + companyId = await auth.getCompanyId(authKey); + } catch (error) { + log.error({ err: error }, 'whoami: getCompanyId failed'); + return res.status(500).json({ message: "Error!", error: String(error) }); + } + + if (companyId === -1) { + return res.status(200).json({ + authenticated: false, + isMaster: false, + companyId: null, + }); + } + + return res.status(200).json({ + authenticated: true, + isMaster: false, + companyId, + }); +}; diff --git a/app/routers/router.js b/app/routers/router.js index 16d38e1..095ddf3 100644 --- a/app/routers/router.js +++ b/app/routers/router.js @@ -22,6 +22,7 @@ const purchaseOrderVendor = require('../controllers/purchaseordervendorcontrolle const purchaseOrderHeader = require('../controllers/purchaseorderheadercontroller.js'); const purchaseOrderLine = require('../controllers/purchaseorderlinecontroller.js'); const inventoryTransaction = require('../controllers/inventorytransactioncontroller.js'); +const whoami = require('../controllers/whoamicontroller.js'); const openapiSpec = require('../config/openapi.js'); const v = require('../middleware/validate.js'); const customerSchemas = require('../schemas/customer.schema.js'); @@ -45,6 +46,11 @@ const inventoryTransactionSchemas = require('../schemas/inventorytransaction.sch // of the API process and reachability of the database. router.get('/healthz', health.healthz); +// Identity probe — returns what the calling authKey resolves to. +// Useful for SDK clients confirming credentials without firing a +// domain call. Mounted under /v1 because it's authKey-scoped. +router.get('/v1/whoami', whoami.whoami); + // OpenAPI: machine-readable spec at /openapi.json, interactive // Swagger UI at /docs. Both are unauthenticated by design — the // spec is the public contract, not a secret. diff --git a/tests/api/whoami.test.js b/tests/api/whoami.test.js new file mode 100644 index 0000000..d107184 --- /dev/null +++ b/tests/api/whoami.test.js @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark +// +// HTTP tests for GET /v1/whoami. +// +// Same constraint as the other API tests in this directory: +// vi.mock on db.config.js does NOT actually intercept the nested +// CJS require chain — the controller still talks to the real +// (unconfigured) Sequelize, which fails and falls through the +// try/catch to the "unknown key" branch. The recognized-key +// branches (master / scoped) need the integration suite to cover. +// +// What this file CAN verify without a working mock: +// - the route is mounted (not 404) +// - 403 when authKey header is missing +// - DB-failure fallback returns the documented unknown-key shape +// - response is always JSON with the documented top-level keys + +import { describe, test, expect, vi, beforeAll } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +vi.mock('../../app/config/db.config.js', () => ({ + sequelize: { + query: vi.fn().mockResolvedValue([]), + QueryTypes: { SELECT: 'SELECT' }, + }, + Sequelize: {}, + Customer: {}, TimeEntry: {}, Worker: {}, BillingType: {}, InventoryItem: {}, + Company: {}, Job: {}, Invoice: {}, CustomerPayment: {}, + InvoiceJob: {}, ProductEntry: {}, VersionInfo: {}, + PurchaseOrderVendor: {}, PurchaseOrderHeader: {}, PurchaseOrderLine: {}, + InventoryTransaction: {}, + ApiKey: {}, ApiMaster: {}, +})); + +let app; + +beforeAll(async () => { + const router = (await import('../../app/routers/router.js')).default + || require('../../app/routers/router.js'); + app = express(); + app.use(express.json()); + app.use('/', router); +}); + +describe('GET /v1/whoami auth contract', () => { + test('returns 403 when authKey header is missing', async () => { + const res = await request(app).get('/v1/whoami'); + expect(res.status).toBe(403); + expect(res.body.message).toMatch(/Authorization key not sent/i); + }); + + test('route is mounted (handler runs, not express default 404)', async () => { + const res = await request(app).get('/v1/whoami').set('authKey', 'anything'); + expect(res.body).toBeTypeOf('object'); + // Either the well-formed whoami shape OR an error message — both prove + // the handler ran. Express default 404 would lack both. + expect( + res.body.authenticated !== undefined || res.body.message !== undefined, + ).toBe(true); + }); +}); + +describe('GET /v1/whoami response shape', () => { + test('DB-unreachable fallback returns the unknown-key shape', async () => { + // Under test-env's broken DB, both isMaster and getCompanyId + // hit their catch blocks and return false/-1. Controller maps + // that to the documented "unrecognized key" response. + const res = await request(app).get('/v1/whoami').set('authKey', 'no-such-key'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + authenticated: false, + isMaster: false, + companyId: null, + }); + }); + + test('response always has the documented three top-level keys', async () => { + const res = await request(app).get('/v1/whoami').set('authKey', 'anything'); + if (res.status === 200) { + expect(Object.keys(res.body).sort()).toEqual( + ['authenticated', 'companyId', 'isMaster'], + ); + expect(typeof res.body.authenticated).toBe('boolean'); + expect(typeof res.body.isMaster).toBe('boolean'); + // companyId is integer or null + expect( + res.body.companyId === null || Number.isInteger(res.body.companyId), + ).toBe(true); + } + }); +});