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
30 changes: 30 additions & 0 deletions app/config/openapi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
73 changes: 73 additions & 0 deletions app/controllers/whoamicontroller.js
Original file line number Diff line number Diff line change
@@ -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": <int>|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,
});
};
6 changes: 6 additions & 0 deletions app/routers/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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.
Expand Down
93 changes: 93 additions & 0 deletions tests/api/whoami.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading