maestro/src/bridge/auth.has-local-credential.test.ts
oss-sync 2044f0a2c4
Some checks failed
CI / build-and-test (push) Failing after 7m0s
sync: update from private repo (91d8d79c)
2026-07-09 23:57:26 +00:00

97 lines
3.8 KiB
TypeScript

/**
* 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);
});
});