mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test(oidc): expand integration test coverage for cookies, API keys, logout, session expiry
This commit is contained in:
@@ -71,6 +71,25 @@ function createOidcUser(opts: { username?: string; email?: string; role?: string
|
||||
return { userId, username, sessionToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a session for an existing user with custom options.
|
||||
*/
|
||||
function createOidcSession(
|
||||
userId: string,
|
||||
opts: { expiresAt?: Date; idToken?: string | null } = {},
|
||||
): string {
|
||||
const sessionToken = randomUUID();
|
||||
db.insert(schema.sessions)
|
||||
.values({
|
||||
id: sessionToken,
|
||||
userId,
|
||||
expiresAt: opts.expiresAt ?? new Date(Date.now() + 3_600_000),
|
||||
idToken: opts.idToken ?? null,
|
||||
})
|
||||
.run();
|
||||
return sessionToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a "hybrid" user -- has both a local password AND an OIDC link.
|
||||
*/
|
||||
@@ -653,3 +672,231 @@ describe("Admin operations on OIDC users", () => {
|
||||
expect(user?.role).toBe("editor");
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// COOKIE-BASED SESSION AUTH
|
||||
// =====================================================================
|
||||
describe("Cookie-based session auth", () => {
|
||||
it("OIDC session cookie works for authenticated requests", async () => {
|
||||
const { sessionToken, username } = createOidcUser();
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.user.username).toBe(username);
|
||||
expect(body.user.authProvider).toBe("oidc");
|
||||
});
|
||||
|
||||
it("Both cookie and Bearer work simultaneously", async () => {
|
||||
const { sessionToken: oidcToken, username: oidcUsername } = createOidcUser();
|
||||
|
||||
// Bearer token for local user (admin)
|
||||
const bearerRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
expect(bearerRes.statusCode).toBe(200);
|
||||
const bearerBody = JSON.parse(bearerRes.body);
|
||||
expect(bearerBody.user.username).toBe("admin");
|
||||
expect(bearerBody.user.authProvider).toBe("local");
|
||||
|
||||
// Cookie for OIDC user
|
||||
const cookieRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
cookies: { "snapotter-session": oidcToken },
|
||||
});
|
||||
|
||||
expect(cookieRes.statusCode).toBe(200);
|
||||
const cookieBody = JSON.parse(cookieRes.body);
|
||||
expect(cookieBody.user.username).toBe(oidcUsername);
|
||||
expect(cookieBody.user.authProvider).toBe("oidc");
|
||||
});
|
||||
|
||||
it("request with neither Bearer nor cookie returns 401", async () => {
|
||||
const res = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// SESSION EXPIRY
|
||||
// =====================================================================
|
||||
describe("Session expiry", () => {
|
||||
it("expired OIDC session returns 401", async () => {
|
||||
const { userId } = createOidcUser();
|
||||
|
||||
// Create a session that expired 10 minutes ago
|
||||
const expiredToken = createOidcSession(userId, {
|
||||
expiresAt: new Date(Date.now() - 600_000),
|
||||
});
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
cookies: { "snapotter-session": expiredToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
// Verify the expired session was cleaned up from DB
|
||||
const session = db
|
||||
.select()
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.id, expiredToken))
|
||||
.get();
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// API KEYS FOR OIDC USERS
|
||||
// =====================================================================
|
||||
describe("API keys for OIDC users", () => {
|
||||
it("OIDC user can create an API key", async () => {
|
||||
const { sessionToken } = createOidcUser();
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/api-keys",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
payload: { name: "test-key" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.key).toMatch(/^si_/);
|
||||
expect(body.name).toBe("test-key");
|
||||
});
|
||||
|
||||
it("API key works for auth after creation", async () => {
|
||||
const { sessionToken } = createOidcUser();
|
||||
|
||||
// Create the API key via cookie auth
|
||||
const createRes = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/api-keys",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
payload: { name: "auth-test-key" },
|
||||
});
|
||||
|
||||
expect(createRes.statusCode).toBe(201);
|
||||
const { key: rawKey } = JSON.parse(createRes.body);
|
||||
|
||||
// Use the raw API key as Bearer token
|
||||
const sessionRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/session",
|
||||
headers: { authorization: `Bearer ${rawKey}` },
|
||||
});
|
||||
|
||||
// API key auth on GET /api/auth/session is a public route, but
|
||||
// the middleware still attaches the user if a valid token is found.
|
||||
// The session endpoint itself checks for session, not API key,
|
||||
// so it returns 401 since the si_ token is not a session ID.
|
||||
// Instead, test with a non-public route that requires auth.
|
||||
const healthRes = await testApp.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/api-keys",
|
||||
headers: { authorization: `Bearer ${rawKey}` },
|
||||
});
|
||||
|
||||
expect(healthRes.statusCode).toBe(200);
|
||||
const body = JSON.parse(healthRes.body);
|
||||
expect(body.apiKeys).toBeDefined();
|
||||
expect(Array.isArray(body.apiKeys)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// LOGOUT
|
||||
// =====================================================================
|
||||
describe("Logout", () => {
|
||||
it("logout clears the snapotter-session cookie", async () => {
|
||||
const { sessionToken } = createOidcUser();
|
||||
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/logout",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.ok).toBe(true);
|
||||
|
||||
// Check that response includes a Set-Cookie header clearing the session
|
||||
const setCookieHeader = res.headers["set-cookie"];
|
||||
const cookieStr = Array.isArray(setCookieHeader)
|
||||
? setCookieHeader.join("; ")
|
||||
: setCookieHeader || "";
|
||||
expect(cookieStr).toContain("snapotter-session=");
|
||||
// The cookie should be expired (Expires in the past or Max-Age=0)
|
||||
const hasExpiry =
|
||||
cookieStr.toLowerCase().includes("max-age=0") ||
|
||||
cookieStr.toLowerCase().includes("expires=thu, 01 jan 1970");
|
||||
expect(hasExpiry).toBe(true);
|
||||
});
|
||||
|
||||
it("session is deleted from DB after logout", async () => {
|
||||
const { sessionToken } = createOidcUser();
|
||||
|
||||
// Verify session exists before logout
|
||||
const before = db
|
||||
.select()
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.id, sessionToken))
|
||||
.get();
|
||||
expect(before).toBeDefined();
|
||||
|
||||
await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/logout",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
});
|
||||
|
||||
// Verify session is gone
|
||||
const after = db
|
||||
.select()
|
||||
.from(schema.sessions)
|
||||
.where(eq(schema.sessions.id, sessionToken))
|
||||
.get();
|
||||
expect(after).toBeUndefined();
|
||||
});
|
||||
|
||||
it("logout returns logoutUrl when session has idToken and OIDC discovery is cached", async () => {
|
||||
const { sessionToken } = createOidcUser();
|
||||
|
||||
// The default createOidcUser sets idToken to "mock-id-token-jwt".
|
||||
// Without a running OIDC provider and cached discovery, the logout
|
||||
// route's try/catch will swallow the error and return no logoutUrl.
|
||||
// We still verify the response shape is correct.
|
||||
const res = await testApp.app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/logout",
|
||||
cookies: { "snapotter-session": sessionToken },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.ok).toBe(true);
|
||||
// logoutUrl is only present when OIDC discovery has been cached with
|
||||
// an end_session_endpoint. In test env without mock provider, it is
|
||||
// absent. We verify it's either undefined or a string URL.
|
||||
if (body.logoutUrl !== undefined) {
|
||||
expect(typeof body.logoutUrl).toBe("string");
|
||||
expect(body.logoutUrl).toContain("id_token_hint=");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user