/** * hasLocalCredential on the session user (issue #799): drives whether the * password-change UI (Settings → Preferences) is shown to a user. It's * computed by passport.deserializeUser (see auth.ts), so it must be verified * on a REAL subsequent request — not just the login response — through a * protected route. * * setupAuth ONCE per file (beforeAll), mirroring admin-api.local.test.ts: * passport.serializeUser/deserializeUser register onto a global stack and * don't replace, so calling setupAuth per-test would leave earlier tests' * deserializers (bound to already-closed repos) running first and erroring * out the whole chain. A dedicated file also keeps this isolated from * auth.local.test.ts's per-test setupAuth calls (same global passport * singleton within one file/worker). */ import { afterAll, beforeAll, describe, it, expect } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import express, { type Express } from 'express'; import request from 'supertest'; import { Repository } from '../db/repository.js'; import { runMigrations } from '../db/migrate.js'; import { setupAuth } from './auth.js'; import type { AuthConfig } from '../config.js'; const AUTH: AuthConfig = { sessionSecret: 'test', sessionMaxAge: 3600_000, secureCookie: false, adminEmails: [], providers: {}, local: { enabled: true, allowSignup: true }, }; describe('hasLocalCredential on the session user', () => { let tempDir = ''; let repo: Repository; let app: Express; beforeAll(() => { tempDir = mkdtempSync(join(tmpdir(), 'maestro-haslocalcred-')); repo = new Repository(join(tempDir, 'orchestrator.db')); runMigrations(repo.getDb()); app = express(); const auth = setupAuth(repo, AUTH); app.use(auth.sessionMiddleware); app.use(auth.passportInit); app.use(auth.passportSession); app.use('/auth', auth.authRouter); app.get('/api/auth/me', (req, res) => { if (!req.isAuthenticated() || !req.user) { res.status(401).json({ error: 'unauthenticated' }); return; } res.json(req.user); }); // Test-only login shortcut: bypasses password verification so a test can // put an arbitrary existing (e.g. OAuth-created) user id into the session // and exercise passport.deserializeUser on the next request — the same // code path a real OAuth callback's req.login would take. app.post('/test/login/:id', (req, res, next) => { req.login({ id: req.params.id } as Express.User, (err) => { if (err) { next(err); return; } res.sendStatus(204); }); }); }); afterAll(() => { repo.close(); if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('is true after a local login', async () => { repo.createLocalUser({ email: 'local-cred@x.com', password: 'pw12345678', role: 'user', status: 'active' }); const agent = request.agent(app); await agent.post('/auth/local').type('form').send({ email: 'local-cred@x.com', password: 'pw12345678' }); const res = await agent.get('/api/auth/me'); expect(res.status).toBe(200); expect(res.body.hasLocalCredential).toBe(true); // secret material must never leak onto the session user expect(res.body.password).toBeUndefined(); expect(res.body.passwordHash).toBeUndefined(); }); it('is false for an OAuth-only account', async () => { const oauthUser = repo.findOrCreateUserByOAuth({ provider: 'gitea', providerId: 'gitea-123', email: 'oauth@x.com', name: 'OAuth User', avatarUrl: undefined, }); repo.updateUser(oauthUser.id, { status: 'active' }); const agent = request.agent(app); await agent.post(`/test/login/${oauthUser.id}`); const res = await agent.get('/api/auth/me'); expect(res.status).toBe(200); expect(res.body.hasLocalCredential).toBe(false); }); });