import { describe, expect, it, afterEach } from 'vitest'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import express, { Request, Response, NextFunction } from 'express'; import request from 'supertest'; import { Repository } from '../db/repository.js'; import { mountUsersApi } from './users-api.js'; describe('GET /api/jobs/:id visibility', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); it('non-viewer gets null from repo.getJob (drives 404 in handler)', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-vis-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const job = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'x', pieceName: 'chat', ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null, }); const bobUser: Express.User = { id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private' as const, defaultVisibilityOrgId: null, }; // Verify at the data layer: bob (non-owner, no orgs) cannot see alice's private job. expect(await repo.getJob(job.id, { viewer: bobUser })).toBeNull(); // Alice (owner) can. expect(await repo.getJob(job.id, { viewer: aliceUser })).not.toBeNull(); // Internal callers (no viewer) still get the row (worker/scheduler pass-through). expect(await repo.getJob(job.id)).not.toBeNull(); } finally { repo.close(); } }); it('admin sees any job regardless of visibility', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-vis-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const job = await repo.createJob({ repo: 'local/task-1', issueNumber: 1, instruction: 'x', pieceName: 'chat', ownerId: alice.id, visibility: 'private', visibilityScopeOrgId: null, }); const adminUser: Express.User = { id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null, role: 'admin', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; expect(await repo.getJob(job.id, { viewer: adminUser })).not.toBeNull(); } finally { repo.close(); } }); }); describe('GET /api/users/me/orgs', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); /** * Build a test app that mounts the REAL /api/users/me/orgs route via * mountUsersApi (the same entry point createCoreServer uses) and injects a * mocked req.user ahead of it. Pass `injectUser = null` to skip injection * and exercise requireAuth. */ function buildApp( repo: Repository, injectUser: (Partial & { id: string }) | null, ): express.Application { const app = express(); if (injectUser) { app.use((req: Request, _res: Response, next: NextFunction) => { (req as Request & { user: Express.User }).user = { email: 'u@x.com', name: 'u', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, ...injectUser, } as Express.User; (req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true; next(); }); // authActive=false: skip requireAuth (we pre-populate req.user above). mountUsersApi(app, repo, false); } else { // authActive=true: exercise the real requireAuth guard. isAuthenticated() // is missing so requireAuth should return 401. app.use((req: Request, _res: Response, next: NextFunction) => { (req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false; next(); }); mountUsersApi(app, repo, true); } return app; } it('returns 401 when the request is unauthenticated (requireAuth gate)', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const app = buildApp(repo, null); const res = await request(app).get('/api/users/me/orgs'); expect(res.status).toBe(401); expect(res.body.error).toBe('Unauthorized'); } finally { repo.close(); } }); it('returns the cached gitea orgs for the authenticated user', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'Alice', role: 'user', status: 'active' }); repo.replaceUserGiteaOrgs(alice.id, [ { orgId: 'org-1', orgName: 'alpha' }, { orgId: 'org-2', orgName: 'beta' }, ]); const app = buildApp(repo, { id: alice.id }); const res = await request(app).get('/api/users/me/orgs'); expect(res.status).toBe(200); expect(res.body.orgs).toHaveLength(2); // listUserGiteaOrgs ORDERs by org_name ASC expect(res.body.orgs[0].orgName).toBe('alpha'); expect(res.body.orgs[1].orgName).toBe('beta'); expect(res.body.orgs[0].orgId).toBe('org-1'); } finally { repo.close(); } }); it('returns empty array when user has no cached orgs', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-orgs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const bob = repo.createUser({ email: 'b@x.com', name: 'Bob', role: 'user', status: 'active' }); const app = buildApp(repo, { id: bob.id }); const res = await request(app).get('/api/users/me/orgs'); expect(res.status).toBe(200); expect(res.body.orgs).toEqual([]); } finally { repo.close(); } }); }); describe('PATCH /api/users/me/preferences', () => { let tempDir = ''; afterEach(() => { if (tempDir) { rmSync(tempDir, { recursive: true, force: true }); tempDir = ''; } }); function buildApp( repo: Repository, injectUser: (Partial & { id: string }) | null, ): express.Application { const app = express(); if (injectUser) { app.use((req: Request, _res: Response, next: NextFunction) => { (req as Request & { user: Express.User }).user = { email: 'u@x.com', name: 'u', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, ...injectUser, } as Express.User; (req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => true; next(); }); mountUsersApi(app, repo, false); } else { app.use((req: Request, _res: Response, next: NextFunction) => { (req as Request & { isAuthenticated: () => boolean }).isAuthenticated = () => false; next(); }); mountUsersApi(app, repo, true); } return app; } it('returns 400 when defaultVisibility is invalid', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'Alice', role: 'user', status: 'active' }); const app = buildApp(repo, { id: alice.id }); const res = await request(app) .patch('/api/users/me/preferences') .send({ defaultVisibility: 'bogus' }); expect(res.status).toBe(400); expect(res.body.error).toBe('invalid defaultVisibility'); } finally { repo.close(); } }); it('returns 400 when defaultVisibilityOrgId is not one of the user orgs', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'Alice', role: 'user', status: 'active' }); const app = buildApp(repo, { id: alice.id, orgIds: ['10'] }); const res = await request(app) .patch('/api/users/me/preferences') .send({ defaultVisibility: 'org', defaultVisibilityOrgId: '99' }); expect(res.status).toBe(400); } finally { repo.close(); } }); it('returns 400 when defaultVisibility=org is sent without defaultVisibilityOrgId', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'Alice', role: 'user', status: 'active' }); const app = buildApp(repo, { id: alice.id, orgIds: ['10'] }); for (const payload of [ { defaultVisibility: 'org' }, { defaultVisibility: 'org', defaultVisibilityOrgId: null }, { defaultVisibility: 'org', defaultVisibilityOrgId: '' }, ]) { const res = await request(app).patch('/api/users/me/preferences').send(payload); expect(res.status).toBe(400); } expect(repo.getUserById(alice.id)!.defaultVisibility).toBe('private'); } finally { repo.close(); } }); it('writes preferences on valid input and persists them', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const alice = repo.createUser({ email: 'a@x.com', name: 'Alice', role: 'user', status: 'active' }); const app = buildApp(repo, { id: alice.id, orgIds: ['10'] }); const res = await request(app) .patch('/api/users/me/preferences') .send({ defaultVisibility: 'org', defaultVisibilityOrgId: '10' }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); const after = repo.getUserById(alice.id); expect(after!.defaultVisibility).toBe('org'); expect(after!.defaultVisibilityOrgId).toBe('10'); } finally { repo.close(); } }); it('returns 401 when unauthenticated', async () => { tempDir = mkdtempSync(join(tmpdir(), 'server-prefs-')); const repo = new Repository(join(tempDir, 'db.sqlite')); try { const app = buildApp(repo, null); const res = await request(app) .patch('/api/users/me/preferences') .send({ defaultVisibility: 'public' }); expect(res.status).toBe(401); } finally { repo.close(); } }); });