ce652ff5de
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Closes the test-coverage gap flagged by pr-reviewers in cycles 7
and 8. Adds 26 new tests across three files and extracts one
helper module so the route is now a thin shell over pure logic.
- New: src/services/sync-drain-summary.ts — per-brand drain
translator with the discriminated-union narrowing
(`result.skipped === true`) the cycle-8 reviewer flagged as
fragile. Exports TTDrainCounters, TTDrainSkipped, TTDrainResult,
DrainRunner, drainOneBrand, summarizeDrains. import server-only.
- New: tests/unit/resolve-workspace-token.test.ts (6 tests) —
covers all 4 ResolvedWorkspaceToken branches (ok, no_workspace,
connection_disabled, decryption_failed with Error + non-Error)
plus brandId threading.
- New: tests/unit/sync-drain-summary.test.ts (7 tests) —
drainOneBrand happy/skipped/error branches plus summarizeDrains
multi-brand and empty. Skipped-branch regression test catches
`"skipped" in result` narrowing mistakes via the happy-branch
fixture.
- New: tests/unit/time-tracking-smartsheet-sync-route.test.ts
(13 tests) — POST + GET auth (Bearer header, ?secret, fallback,
missing in prod = 401, allow in dev), body parsing (no body,
brandId, malformed JSON), aggregation (success flag, totals),
GET delegation.
Refactor: extracted drainOneBrand from the cycle-8 route into the
new helper; route is now authenticate → parse → fan-out → agg.
Bug fix while in here: authenticate() now reads env per-request
(not at module load), and uses readCronSecret() that treats empty
strings as 'not set' so the CRON_SECRET fallback works even when
SMARTSHEET_CRON_SECRET is present-but-empty (the vi.stubEnv idiom
and some hosting dashboards).
vitest.config.ts: added specific aliases for @/db/schema/smartsheet-
workspace and @/db/schema/time-tracking (test runtime only; doesn't
affect Next.js builds).
Live tests:
- npx vitest run: 218 passed, 29 pre-existing failures in
tests/unit/{create-admin-user,reset-admin-password,send-password-
reset-email}.test.ts (unrelated to cycle 9; same baseline as cycle
8).
- npx tsc --noEmit: clean (no new errors).
- npm run lint: no new warnings on cycle 9 files.
- npm run build: EXIT 0 (41s); new route still registered.
- Smoke (port 4000): GET ?secret=qa-audit-cron-secret → 200;
GET (no auth) → 401; GET ?secret=wrong → 401.
PR-reviewer verdict: APPROVED. Three style notes fixed before
commit (test comment about narrowing, vi.stubEnv for NODE_ENV,
named TTDrainResult type).
Co-authored-by: cyc9-bot <bot@routecomm>
205 lines
6.2 KiB
TypeScript
205 lines
6.2 KiB
TypeScript
/**
|
|
* Unit tests for `resolveWorkspaceToken` — the security-critical
|
|
* helper that returns the plaintext Smartsheet API token for a brand.
|
|
*
|
|
* Every async export of a "use server" file is reachable as a server
|
|
* action RPC, so this helper was extracted to a plain server-only
|
|
* module (cycle 7) precisely to prevent accidental client exposure.
|
|
* The 4-branch discriminated union below is the security boundary —
|
|
* if it ever leaks or collapses, tokens can leak.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const { mockWithBrand, mockDecryptToken } = vi.hoisted(() => ({
|
|
mockWithBrand: vi.fn(),
|
|
mockDecryptToken: vi.fn(),
|
|
}));
|
|
|
|
// Stub the server-only guard so the module can be imported under vitest.
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
// Mock the Drizzle client wrapper so we can simulate per-brand reads
|
|
// without hitting Postgres.
|
|
vi.mock("@/db/client", () => ({
|
|
withBrand: mockWithBrand,
|
|
}));
|
|
|
|
// Mock the AES-256-GCM decryption layer so we can simulate bad
|
|
// ciphertext without managing real keys / IVs.
|
|
vi.mock("@/lib/crypto", () => ({
|
|
decryptToken: mockDecryptToken,
|
|
}));
|
|
|
|
import { resolveWorkspaceToken } from "@/services/workspace-token";
|
|
|
|
beforeEach(() => {
|
|
mockWithBrand.mockReset();
|
|
mockDecryptToken.mockReset();
|
|
});
|
|
|
|
describe("resolveWorkspaceToken", () => {
|
|
it("returns ok:true with the plaintext token when the workspace exists, is enabled, and decrypts", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [
|
|
{
|
|
brandId: "brand-1",
|
|
encryptedApiToken: "cipher",
|
|
tokenIv: "iv",
|
|
tokenAuthTag: "tag",
|
|
connectionEnabled: true,
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
mockDecryptToken.mockReturnValueOnce("plain-token-abc");
|
|
|
|
const result = await resolveWorkspaceToken("brand-1");
|
|
expect(result).toEqual({ ok: true, token: "plain-token-abc" });
|
|
expect(mockDecryptToken).toHaveBeenCalledWith({
|
|
cipher: "cipher",
|
|
iv: "iv",
|
|
authTag: "tag",
|
|
});
|
|
});
|
|
|
|
it("returns ok:false reason:no_workspace when the workspace row does not exist", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const result = await resolveWorkspaceToken("brand-1");
|
|
expect(result).toEqual({ ok: false, reason: "no_workspace" });
|
|
expect(mockDecryptToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns ok:false reason:connection_disabled when the workspace is paused", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [
|
|
{
|
|
brandId: "brand-1",
|
|
encryptedApiToken: "cipher",
|
|
tokenIv: "iv",
|
|
tokenAuthTag: "tag",
|
|
connectionEnabled: false,
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
const result = await resolveWorkspaceToken("brand-1");
|
|
expect(result).toEqual({ ok: false, reason: "connection_disabled" });
|
|
expect(mockDecryptToken).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns ok:false reason:decryption_failed with the underlying error message", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [
|
|
{
|
|
brandId: "brand-1",
|
|
encryptedApiToken: "cipher",
|
|
tokenIv: "iv",
|
|
tokenAuthTag: "tag",
|
|
connectionEnabled: true,
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
mockDecryptToken.mockImplementationOnce(() => {
|
|
throw new Error("Unsupported state or unable to authenticate data");
|
|
});
|
|
|
|
const result = await resolveWorkspaceToken("brand-1");
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
reason: "decryption_failed",
|
|
error: "Unsupported state or unable to authenticate data",
|
|
});
|
|
});
|
|
|
|
it("wraps non-Error thrown values in a string for decryption_failed", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [
|
|
{
|
|
brandId: "brand-1",
|
|
encryptedApiToken: "cipher",
|
|
tokenIv: "iv",
|
|
tokenAuthTag: "tag",
|
|
connectionEnabled: true,
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
mockDecryptToken.mockImplementationOnce(() => {
|
|
throw "string-throw-not-error";
|
|
});
|
|
|
|
const result = await resolveWorkspaceToken("brand-1");
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
reason: "decryption_failed",
|
|
error: "string-throw-not-error",
|
|
});
|
|
});
|
|
|
|
it("threads the brandId through to withBrand", async () => {
|
|
mockWithBrand.mockImplementationOnce(
|
|
async (_brandId: string, fn: (db: unknown) => Promise<unknown>) =>
|
|
fn({
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => [],
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await resolveWorkspaceToken("the-right-brand");
|
|
expect(mockWithBrand).toHaveBeenCalledWith(
|
|
"the-right-brand",
|
|
expect.any(Function),
|
|
);
|
|
});
|
|
}); |