maestro/src/bridge/scheduled-tasks-api.test.ts
oss-sync 483464597a
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (a360d15)
2026-06-09 09:19:09 +00:00

569 lines
19 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Repository } from '../db/repository.js';
import { BrowserSessionRepo } from '../db/browser-session-repo.js';
import { Scheduler } from '../scheduler.js';
import { mountScheduledTasksApi } from './scheduled-tasks-api.js';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
let app: express.Application;
let repo: Repository;
let scheduler: Scheduler;
let tempDir: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-api-'));
repo = new Repository(join(tempDir, 'test.db'));
scheduler = new Scheduler(repo, join(tempDir, 'workspaces'));
app = express();
app.use(express.json());
mountScheduledTasksApi(app, repo, scheduler);
});
afterAll(() => {
repo.close();
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
});
describe('POST /api/scheduled-tasks with visibility', () => {
let vTempDir = '';
let vRepo: Repository;
let vApp: express.Application;
let aliceUser: Express.User;
beforeEach(() => {
vTempDir = mkdtempSync(join(tmpdir(), 'sched-vis-api-'));
vRepo = new Repository(join(vTempDir, 'db.sqlite'));
const real = vRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
aliceUser = {
...real,
orgIds: ['10'],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const vScheduler = new Scheduler(vRepo, join(vTempDir, 'workspaces'));
vApp = express();
vApp.use(express.json());
vApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = aliceUser;
next();
});
mountScheduledTasksApi(vApp, vRepo, vScheduler);
});
afterEach(() => {
vRepo.close();
rmSync(vTempDir, { recursive: true, force: true });
});
it('creates scheduled task with owner_id set from req.user and visibility=org', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
minute: 0,
visibility: 'org',
visibilityScopeOrgId: '10',
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('org');
expect(res.body.task.visibilityScopeOrgId).toBe('10');
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('defaults visibility to private and owner from req.user when not provided', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 10,
});
expect(res.status).toBe(201);
expect(res.body.task.visibility).toBe('private');
expect(res.body.task.visibilityScopeOrgId).toBeNull();
expect(res.body.task.ownerId).toBe(aliceUser.id);
});
it('rejects visibility=org with org not in user orgs', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'org',
visibilityScopeOrgId: '99',
});
expect(res.status).toBe(400);
});
it('rejects invalid visibility enum values', async () => {
const res = await request(vApp).post('/api/scheduled-tasks').send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
visibility: 'bogus',
});
expect(res.status).toBe(400);
});
});
describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => {
let nTempDir = '';
let nRepo: Repository;
let nApp: express.Application;
beforeEach(() => {
nTempDir = mkdtempSync(join(tmpdir(), 'sched-noauth-api-'));
nRepo = new Repository(join(nTempDir, 'db.sqlite'));
const nScheduler = new Scheduler(nRepo, join(nTempDir, 'workspaces'));
nApp = express();
nApp.use(express.json());
// No user middleware: no-auth deployment (req.user undefined). authActive:false
// tells the router to register the scheduled task under the 'local' owner so
// the jobs the scheduler later spawns inherit it (not NULL).
mountScheduledTasksApi(nApp, nRepo, nScheduler, { authActive: false });
});
afterEach(() => {
nRepo.close();
rmSync(nTempDir, { recursive: true, force: true });
});
it('registers the scheduled task under owner "local" instead of NULL', async () => {
const res = await request(nApp).post('/api/scheduled-tasks').send({
body: 'nightly thing',
scheduleType: 'daily',
hour: 9,
minute: 0,
});
expect(res.status).toBe(201);
expect(res.body.task.ownerId).toBe('local');
});
});
describe('POST /api/scheduled-tasks', () => {
it('should create a daily schedule', async () => {
const res = await request(app)
.post('/api/scheduled-tasks')
.send({
title: 'テスト日次',
body: 'テストプロンプト',
scheduleType: 'daily',
hour: 9,
minute: 0,
});
expect(res.status).toBe(201);
expect(res.body.task.cronExpression).toBe('0 9 * * *');
expect(res.body.task.isActive).toBe(true);
});
it('should require body', async () => {
const res = await request(app).post('/api/scheduled-tasks').send({ scheduleType: 'daily' });
expect(res.status).toBe(400);
});
});
describe('GET /api/scheduled-tasks', () => {
it('should list all scheduled tasks', async () => {
const res = await request(app).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(Array.isArray(res.body.tasks)).toBe(true);
});
});
describe('PATCH /api/scheduled-tasks/:id', () => {
it('should pause and resume', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'pause-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const pauseRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: false });
expect(pauseRes.body.task.isActive).toBe(false);
const resumeRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: true });
expect(resumeRes.body.task.isActive).toBe(true);
});
});
describe('DELETE /api/scheduled-tasks/:id', () => {
it('should delete a scheduled task', async () => {
const createRes = await request(app)
.post('/api/scheduled-tasks')
.send({ title: 'delete-test', body: 'test', scheduleType: 'daily', hour: 10 });
const id = createRes.body.task.id;
const delRes = await request(app).delete(`/api/scheduled-tasks/${id}`);
expect(delRes.status).toBe(200);
const getRes = await request(app).get(`/api/scheduled-tasks/${id}`);
expect(getRes.status).toBe(404);
});
});
describe('PATCH/DELETE /api/scheduled-tasks/:id owner-or-admin', () => {
let pTempDir = '';
let pRepo: Repository;
afterEach(() => {
pRepo.close();
rmSync(pTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const pScheduler = new Scheduler(pRepo, join(pTempDir, 'workspaces'));
const pApp = express();
pApp.use(express.json());
pApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(pApp, pRepo, pScheduler);
return pApp;
}
function seedTask(ownerId: string, visibility: 'private' | 'org' | 'public' = 'public') {
return pRepo.createScheduledTask({
title: 't',
body: 'b',
cronExpression: '0 9 * * *',
nextRunAt: '2099-01-01 09:00:00',
ownerId,
visibility,
});
}
it('non-owner non-admin gets 404 on PATCH (even when visibility=public)', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
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 pApp = buildAppForUser(bobUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited' });
expect(res.status).toBe(404);
// Task title not changed
const after = await pRepo.getScheduledTask(task.id);
expect(after?.title).toBe('t');
});
it('non-owner non-admin gets 404 on DELETE', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'public');
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 pApp = buildAppForUser(bobUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(404);
const after = await pRepo.getScheduledTask(task.id);
expect(after).not.toBeNull();
});
it('admin can PATCH any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: 'admin@x.com',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-admin' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-admin');
});
it('admin can DELETE any scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const task = await seedTask(alice.id, 'private');
const adminUser: Express.User = {
id: 'admin-id',
email: 'admin@x.com',
name: 'admin',
avatarUrl: null,
role: 'admin',
status: 'active',
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const pApp = buildAppForUser(adminUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
const after = await pRepo.getScheduledTask(task.id);
expect(after).toBeNull();
});
it('owner can PATCH own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp)
.patch(`/api/scheduled-tasks/${task.id}`)
.send({ title: 'edited-by-owner' });
expect(res.status).toBe(200);
expect(res.body.task.title).toBe('edited-by-owner');
});
it('owner can DELETE own scheduled task', async () => {
pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-'));
pRepo = new Repository(join(pTempDir, 'db.sqlite'));
const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice,
orgIds: [],
defaultVisibility: 'private',
defaultVisibilityOrgId: null,
};
const task = await seedTask(alice.id, 'private');
const pApp = buildAppForUser(aliceUser);
const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});
describe('GET /api/scheduled-tasks visibility filter', () => {
let lTempDir = '';
let lRepo: Repository;
afterEach(() => {
lRepo.close();
rmSync(lTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const lScheduler = new Scheduler(lRepo, join(lTempDir, 'workspaces'));
const lApp = express();
lApp.use(express.json());
lApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(lApp, lRepo, lScheduler);
return lApp;
}
it('non-owner does not see private scheduled tasks in list', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
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 res = await request(buildAppForUser(bobUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).not.toContain('alice-private');
});
it('owner sees own private scheduled tasks', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
const aliceUser: Express.User = {
...alice, orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const res = await request(buildAppForUser(aliceUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
it('admin sees all scheduled tasks regardless of visibility', async () => {
lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-'));
lRepo = new Repository(join(lTempDir, 'db.sqlite'));
const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
await lRepo.createScheduledTask({
title: 'alice-private', body: 'b',
cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00',
ownerId: alice.id, visibility: 'private',
});
const adminUser: Express.User = {
id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null,
role: 'admin', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
const res = await request(buildAppForUser(adminUser)).get('/api/scheduled-tasks');
expect(res.status).toBe(200);
expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private');
});
});
describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () => {
let bTempDir = '';
let bRepo: Repository;
let bSessRepo: BrowserSessionRepo;
let alice: { id: string };
let bob: { id: string };
let aliceProfileId: number;
let bobProfileId: number;
beforeEach(() => {
bTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-'));
bRepo = new Repository(join(bTempDir, 'db.sqlite'));
bSessRepo = new BrowserSessionRepo(bRepo.getDb());
alice = bRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' });
bob = bRepo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' });
aliceProfileId = bSessRepo.createProfile({
ownerId: alice.id,
label: 'alice-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
bobProfileId = bSessRepo.createProfile({
ownerId: bob.id,
label: 'bob-twitter',
startUrl: 'https://twitter.com/home',
matchPatterns: ['https://twitter.com/**'],
storageOrigins: ['https://twitter.com'],
loggedInSelector: null,
loginUrlPatterns: [],
});
});
afterEach(() => {
bRepo.close();
rmSync(bTempDir, { recursive: true, force: true });
});
function buildAppForUser(user: Express.User): express.Application {
const bScheduler = new Scheduler(bRepo, join(bTempDir, 'workspaces'));
const bApp = express();
bApp.use(express.json());
bApp.use((req, _res, next) => {
(req as unknown as { user: Express.User }).user = user;
next();
});
mountScheduledTasksApi(bApp, bRepo, bScheduler, { sessRepo: bSessRepo });
return bApp;
}
function asUser(u: { id: string }, email: string): Express.User {
return {
id: u.id, email, name: 'x', avatarUrl: null,
role: 'user', status: 'active', orgIds: [],
defaultVisibility: 'private', defaultVisibilityOrgId: null,
};
}
it('accepts a valid profile owned by the requesting user (201)', async () => {
const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: aliceProfileId,
});
expect(res.status).toBe(201);
expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId);
});
it('rejects a profile owned by a different user (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: bobProfileId,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/not owned by you|not found/i);
});
it('rejects a positive integer that does not match any profile (400)', async () => {
const res = await request(buildAppForUser(asUser(alice, 'a@x.com')))
.post('/api/scheduled-tasks')
.send({
body: 'hello',
scheduleType: 'daily',
hour: 9,
browserSessionProfileId: 999999,
});
expect(res.status).toBe(400);
});
});