style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -7,90 +7,90 @@
|
||||
* 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// We import after env manipulation since devAuthBypass() reads env vars at call time.
|
||||
// Each test resets the module registry via vi.resetModules() to re-evaluate the function
|
||||
// with the current process.env values.
|
||||
|
||||
describe('devAuthBypass middleware', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env after each test
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalBypassFlag === undefined) {
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
} else {
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('is a pure passthrough in production (NODE_ENV=production), even when DEV_AUTH_BYPASS=true', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
|
||||
// Import after env setup
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
// Hard guard: user must NOT be injected in production
|
||||
expect(capturedUser).toBeUndefined()
|
||||
})
|
||||
expect(capturedUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a passthrough when NODE_ENV!=production and DEV_AUTH_BYPASS is not set', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
expect(capturedUser).toBeUndefined()
|
||||
})
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects DEV_USER when NODE_ENV!=production and DEV_AUTH_BYPASS=true', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
|
||||
const { devAuthBypass, DEV_USER } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass, DEV_USER } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
// User must be the fixed DEV_USER
|
||||
expect(capturedUser).toBeDefined()
|
||||
expect(capturedUser).toEqual(DEV_USER)
|
||||
expect((capturedUser as typeof DEV_USER).displayName).toBe('Dev User')
|
||||
expect((capturedUser as typeof DEV_USER).oidcSub).toBe('dev-user')
|
||||
})
|
||||
})
|
||||
expect(capturedUser).toBeDefined();
|
||||
expect(capturedUser).toEqual(DEV_USER);
|
||||
expect((capturedUser as typeof DEV_USER).displayName).toBe('Dev User');
|
||||
expect((capturedUser as typeof DEV_USER).oidcSub).toBe('dev-user');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,112 +11,110 @@
|
||||
* pure unit test that runs without MariaDB or any external service.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { persistSessionCookie } from '../../src/auth/persistSessionCookie.js'
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { persistSessionCookie } from '../../src/auth/persistSessionCookie.js';
|
||||
|
||||
// Read the cookie name the same way the implementation does so the test stays correct
|
||||
// if OIDC_COOKIE_NAME is overridden in the environment.
|
||||
const COOKIE_NAME = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'
|
||||
const COOKIE_NAME = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth';
|
||||
|
||||
afterEach(() => {
|
||||
// Nothing to restore: these tests do not mutate process.env.
|
||||
})
|
||||
});
|
||||
|
||||
describe('persistSessionCookie middleware', () => {
|
||||
describe('Test A — persist path (oidcAuthJwt truthy)', () => {
|
||||
it('emits an oidc-auth Set-Cookie with Max-Age, SameSite=Lax, HttpOnly, and Secure', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
// Simulate what @hono/oidc-auth does: set a signed session JWT on context.
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, 'header.payload.sig')
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, 'header.payload.sig');
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
expect(setCookieHeader).not.toBeNull()
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
expect(setCookieHeader).not.toBeNull();
|
||||
|
||||
// Cookie name must appear in the Set-Cookie value.
|
||||
expect(setCookieHeader).toContain(COOKIE_NAME)
|
||||
expect(setCookieHeader).toContain(COOKIE_NAME);
|
||||
|
||||
// Max-Age must be present (persistent cookie, not session-scoped).
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('max-age')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('max-age');
|
||||
|
||||
// SameSite=Lax must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
|
||||
|
||||
// HttpOnly must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('httponly')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('httponly');
|
||||
|
||||
// Secure must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('secure')
|
||||
})
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('secure');
|
||||
});
|
||||
|
||||
it('re-issues the same JWT value the library provided (no re-signing)', async () => {
|
||||
const dummyJwt = 'header.payload.sig'
|
||||
const app = new Hono()
|
||||
const dummyJwt = 'header.payload.sig';
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, dummyJwt)
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, dummyJwt);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
const setCookieHeader = res.headers.get('set-cookie') ?? ''
|
||||
const res = await app.request('/api/test');
|
||||
const setCookieHeader = res.headers.get('set-cookie') ?? '';
|
||||
|
||||
// The cookie value must contain the exact JWT string (URL-encoded = is fine but
|
||||
// the JWT characters must all appear).
|
||||
expect(setCookieHeader).toContain(dummyJwt)
|
||||
})
|
||||
})
|
||||
expect(setCookieHeader).toContain(dummyJwt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test B — guard path (oidcAuthJwt falsy / absent)', () => {
|
||||
it('emits NO oidc-auth Set-Cookie when oidcAuthJwt is not set (no resurrection)', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
// Do NOT set oidcAuthJwt — simulates a logged-out or unauthenticated request.
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
|
||||
// Either no Set-Cookie header at all, or it must not contain the oidc-auth cookie.
|
||||
const hasOidcCookie =
|
||||
setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME)
|
||||
expect(hasOidcCookie).toBe(false)
|
||||
})
|
||||
const hasOidcCookie = setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME);
|
||||
expect(hasOidcCookie).toBe(false);
|
||||
});
|
||||
|
||||
it('emits NO Set-Cookie when oidcAuthJwt is explicitly set to empty string', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, '')
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, '');
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
const res = await app.request('/api/test');
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
|
||||
const hasOidcCookie =
|
||||
setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME)
|
||||
expect(hasOidcCookie).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
const hasOidcCookie = setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME);
|
||||
expect(hasOidcCookie).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-102
@@ -4,7 +4,7 @@
|
||||
* Tests for apps/api/src/auth/user.ts (Plan 02)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the db singleton at module level (Vitest hoisting — must be top-level)
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
@@ -12,16 +12,16 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import after mock is set up
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js'
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js';
|
||||
|
||||
const mockDb = db as {
|
||||
select: ReturnType<typeof vi.fn>
|
||||
insert: ReturnType<typeof vi.fn>
|
||||
}
|
||||
select: ReturnType<typeof vi.fn>;
|
||||
insert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
// Chainable builder factory used in multiple tests
|
||||
function makeSelectChain(resolvedValue: unknown[]) {
|
||||
@@ -29,140 +29,161 @@ function makeSelectChain(resolvedValue: unknown[]) {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn().mockResolvedValue(resolvedValue),
|
||||
}
|
||||
chain.from.mockReturnValue(chain)
|
||||
chain.where.mockReturnValue(chain)
|
||||
return chain
|
||||
};
|
||||
chain.from.mockReturnValue(chain);
|
||||
chain.where.mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain(returningIdValue: { id: number }[]) {
|
||||
const chain = {
|
||||
values: vi.fn(),
|
||||
$returningId: vi.fn().mockResolvedValue(returningIdValue),
|
||||
}
|
||||
chain.values.mockReturnValue(chain)
|
||||
return chain
|
||||
};
|
||||
chain.values.mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe('COLOR_PALETTE', () => {
|
||||
it('exports at least 4 distinct hex colors', () => {
|
||||
expect(COLOR_PALETTE).toBeDefined()
|
||||
expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4)
|
||||
expect(COLOR_PALETTE).toBeDefined();
|
||||
expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4);
|
||||
for (const c of COLOR_PALETTE) {
|
||||
// Each entry must be a 7-char hex string like #4A90D9
|
||||
expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/)
|
||||
expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
}
|
||||
// All colors must be distinct
|
||||
const unique = new Set(COLOR_PALETTE)
|
||||
expect(unique.size).toBe(COLOR_PALETTE.length)
|
||||
})
|
||||
})
|
||||
const unique = new Set(COLOR_PALETTE);
|
||||
expect(unique.size).toBe(COLOR_PALETTE.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('assigns palette[0] to the first user inserted', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-001'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-001';
|
||||
|
||||
// First select: no existing user
|
||||
// Second select (used colors): no existing users → no colors in use → palette[0]
|
||||
// Third select (re-fetch after insert): return the inserted row
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
// Lookup by iss+sub — not found
|
||||
return makeSelectChain([])
|
||||
return makeSelectChain([]);
|
||||
}
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — no existing users
|
||||
return {
|
||||
from: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
}
|
||||
// Re-fetch after insert
|
||||
return makeSelectChain([
|
||||
{ id: 1, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
{
|
||||
id: 1,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }]))
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }]));
|
||||
|
||||
const user = await upsertUser(iss, sub)
|
||||
const user = await upsertUser(iss, sub);
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0])
|
||||
expect(user!.id).toBe(1)
|
||||
})
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0]);
|
||||
expect(user!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('assigns palette[1] to the second distinct user', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub2 = 'user-sub-002'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub2 = 'user-sub-002';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
return makeSelectChain([]) // not found
|
||||
return makeSelectChain([]); // not found
|
||||
}
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — one existing user already holds palette[0],
|
||||
// so the next member must get the first unused color: palette[1].
|
||||
return {
|
||||
from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]),
|
||||
}
|
||||
};
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 2, oidcIss: iss, oidcSub: sub2, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
{
|
||||
id: 2,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub2,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }]))
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }]));
|
||||
|
||||
const user = await upsertUser(iss, sub2)
|
||||
const user = await upsertUser(iss, sub2);
|
||||
|
||||
expect(user!.color).toBe(COLOR_PALETTE[1])
|
||||
})
|
||||
expect(user!.color).toBe(COLOR_PALETTE[1]);
|
||||
});
|
||||
|
||||
// Regression (Gate 2): a new member must get a color NOT already in use, even
|
||||
// after a deletion. The old COUNT(*) % palette logic reused an in-use slot
|
||||
// when the user count had shifted (two members both got #E8734A). With colors
|
||||
// [0] and [2] taken (slot [1] freed by a delete), the next member fills [1].
|
||||
it('assigns the first UNUSED palette color (no collision after deletions)', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-005'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-005';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) return makeSelectChain([]) // not found
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]); // not found
|
||||
if (selectCallCount === 2) {
|
||||
// palette[0] and palette[2] in use; palette[1] is free
|
||||
return {
|
||||
from: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]),
|
||||
}
|
||||
};
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 5, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }]))
|
||||
{
|
||||
id: 5,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }]));
|
||||
|
||||
await upsertUser(iss, sub)
|
||||
await upsertUser(iss, sub);
|
||||
|
||||
// The inserted row's color must be the first unused palette entry (palette[1]).
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]
|
||||
expect(insertValues.color).toBe(COLOR_PALETTE[1])
|
||||
})
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues.color).toBe(COLOR_PALETTE[1]);
|
||||
});
|
||||
|
||||
it('returns the same user row on re-upsert (idempotent — no duplicate insert)', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-001'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-001';
|
||||
const existingRow = {
|
||||
id: 1,
|
||||
oidcIss: iss,
|
||||
@@ -170,51 +191,58 @@ describe('upsertUser', () => {
|
||||
displayName: 'Lucas',
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
// select returns existing row immediately
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]))
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));
|
||||
|
||||
const user = await upsertUser(iss, sub, 'Lucas')
|
||||
const user = await upsertUser(iss, sub, 'Lucas');
|
||||
|
||||
// Must NOT call insert (idempotent path)
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(user!.id).toBe(1)
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0])
|
||||
})
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
expect(user!.id).toBe(1);
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0]);
|
||||
});
|
||||
|
||||
it('uses oidc_iss + oidc_sub as identity key, never email', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-003'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-003';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) return makeSelectChain([])
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]);
|
||||
if (selectCallCount === 2) {
|
||||
return { from: vi.fn().mockResolvedValue([{ count: 0 }]) }
|
||||
return { from: vi.fn().mockResolvedValue([{ count: 0 }]) };
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 3, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }]))
|
||||
{
|
||||
id: 3,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }]));
|
||||
|
||||
// Pass a displayName (e.g. email) — identity still keyed on iss+sub
|
||||
await upsertUser(iss, sub, 'lucas@example.com')
|
||||
await upsertUser(iss, sub, 'lucas@example.com');
|
||||
|
||||
// The insert values must include oidcIss and oidcSub, not email as key
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]
|
||||
expect(insertValues).toBeDefined()
|
||||
expect(insertValues.oidcIss).toBe(iss)
|
||||
expect(insertValues.oidcSub).toBe(sub)
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues).toBeDefined();
|
||||
expect(insertValues.oidcIss).toBe(iss);
|
||||
expect(insertValues.oidcSub).toBe(sub);
|
||||
// No 'email' property should be used as an identity field
|
||||
expect(insertValues).not.toHaveProperty('email')
|
||||
})
|
||||
expect(insertValues).not.toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('returns the full user row including id, color, displayName', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-004'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-004';
|
||||
const existingRow = {
|
||||
id: 42,
|
||||
oidcIss: iss,
|
||||
@@ -222,15 +250,15 @@ describe('upsertUser', () => {
|
||||
displayName: 'Alice',
|
||||
color: '#9B6DC5',
|
||||
createdAt: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]))
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));
|
||||
|
||||
const user = await upsertUser(iss, sub, 'Alice')
|
||||
const user = await upsertUser(iss, sub, 'Alice');
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user!.id).toBe(42)
|
||||
expect(user!.color).toBe('#9B6DC5')
|
||||
expect(user!.displayName).toBe('Alice')
|
||||
})
|
||||
})
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.id).toBe(42);
|
||||
expect(user!.color).toBe('#9B6DC5');
|
||||
expect(user!.displayName).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user