Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/docs/architecture/auth-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ hand-written:

**These two currently agree with each other but not with the derived list.** They contain `all`,
`Assignment`, `Group`, `Instrument`, `InstrumentRecord`, `InstrumentRepo`, `Session`, `Subject`,
`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile` and `SetupState`, which
`User`. The Prisma schema also defines `AuditLog`, `InstrumentRecordFile`, `InstrumentRepoSyncLog`
and `SetupState`, which
are therefore valid in `@RouteAccess` and in `AbilityFactory` rules (`InstrumentRecordFile` is used
in both) but cannot be granted as an `additionalPermission`. Treat that as the existing state, not
as a licence to widen it silently.
Expand Down
5 changes: 4 additions & 1 deletion .agents/docs/architecture/instrument-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ be created before its series.

**A series that references another repository still fails to import.** Nothing resolves dependencies
across repositories or retries afterwards, so whether it works depends on the order the repos were
added. Making this reliable needs a second resolution pass once every repo has been imported.
added. Making this reliable needs a second resolution pass once every repo has been imported. Until
then, the failure and its reason land in the repository's activity log (`InstrumentRepoSyncLog`, on
the admin Instrument Repositories page), and syncing the repository again once its dependencies are
stored imports the series.

## 2. Bundling

Expand Down
4 changes: 2 additions & 2 deletions apps/api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ CASL subjects are derived automatically from the Prisma `TypeMap`, so every mode
without you doing anything. But the subjects a user can be _granted_ through `additionalPermissions`
come from **two hand-maintained lists that must agree with each other**: `enum AppSubject` in
`prisma/schema.prisma` and `$AppSubjectName` in `packages/schemas/src/core/core.ts`. Both currently
list the same eight models plus `all`. Three models — `AuditLog`, `InstrumentRecordFile` and
`SetupState` — appear in neither, so no user can be granted a permission naming them; they are
list the same eight models plus `all`. Four models — `AuditLog`, `InstrumentRecordFile`,
`InstrumentRepoSyncLog` and `SetupState` — appear in neither, so no user can be granted a permission naming them; they are
reachable only through rules the ability factory writes itself. Adding a model that users should be
able to hold a permission on means editing both lists.

Expand Down
34 changes: 34 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,40 @@ model InstrumentRepo {
@@map("InstrumentRepoModel")
}

enum InstrumentRepoSyncAction {
IMPORT
SYNC
}

enum InstrumentRepoSyncStage {
BUNDLE
LOAD
}

type InstrumentRepoSyncFailure {
instrumentDir String
message String
stage InstrumentRepoSyncStage
}

// One entry per import or sync. Not related to InstrumentRepo, so the history outlives a deleted
// repository; `repoName` is denormalized for the same reason.
model InstrumentRepoSyncLog {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
action InstrumentRepoSyncAction
failures InstrumentRepoSyncFailure[]
importedCount Int
message String?
repoId String? @db.ObjectId
repoName String

@@index([createdAt])
@@index([repoId, createdAt])
@@map("InstrumentRepoSyncLogModel")
}

// Subjects

enum Sex {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/core/prisma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ const DATABASE_INDEXES: DatabaseIndex[] = [
{ collection: 'InstrumentRecordModel', fields: ['instrumentId'] },
{ collection: 'InstrumentRecordModel', fields: ['sessionId'] },
{ collection: 'InstrumentRepoModel', fields: ['url'], unique: true },
{ collection: 'InstrumentRepoSyncLogModel', fields: ['createdAt'] },
{ collection: 'InstrumentRepoSyncLogModel', fields: ['repoId', 'createdAt'] },
{ collection: 'SessionModel', fields: ['groupId', 'date'] },
{ collection: 'SessionModel', fields: ['subjectId'] },
{ collection: 'SubjectModel', fields: ['groupIds'] },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,55 @@ describe('InstrumentReposController', () => {
expect(service.findAll).toHaveBeenCalledWith({ ability });
});

it('should sync the requested repository', async () => {
it("should scope the sync of one repository to the current user's ability", async () => {
service.sync.mockResolvedValueOnce(repo);
await expect(controller.sync('repo-1')).resolves.toBe(repo);
expect(service.sync).toHaveBeenCalledWith('repo-1');
await expect(controller.sync('repo-1', ability)).resolves.toBe(repo);
expect(service.sync).toHaveBeenCalledWith('repo-1', { ability });
});

it("should sync every repository the current user's ability allows", async () => {
service.syncAll.mockResolvedValueOnce([]);
await expect(controller.syncAll(ability)).resolves.toStrictEqual([]);
expect(service.syncAll).toHaveBeenCalledWith({ ability });
});

it("should scope the activity log to the current user's ability, and pass the page asked for", async () => {
const query = { limit: 10, page: 2, sortOrder: 'desc' } as const;
const result = { data: [], pageCount: 0, total: 0 };
service.findSyncLogs.mockResolvedValueOnce(result);
await expect(controller.findSyncLogs(query, ability)).resolves.toBe(result);
expect(service.findSyncLogs).toHaveBeenCalledWith(query, { ability });
});

it("should scope the latest entry per repository to the current user's ability", async () => {
service.findLatestSyncLogs.mockResolvedValueOnce([]);
await expect(controller.findLatestSyncLogs(ability)).resolves.toStrictEqual([]);
expect(service.findLatestSyncLogs).toHaveBeenCalledWith({ ability });
});

it("should clear the activity log within the current user's ability", async () => {
service.deleteSyncLogs.mockResolvedValueOnce({ count: 2 });
await expect(controller.deleteSyncLogs(ability)).resolves.toStrictEqual({ count: 2 });
expect(service.deleteSyncLogs).toHaveBeenCalledWith({ ability });
});

it.each(['deleteSyncLogs', 'findLatestSyncLogs', 'findSyncLogs'] as const)(
'should restrict %s to administrators, since the history spans every repository',
(handler) => {
const reflector = new Reflector();
expect(reflector.get(ROUTE_ACCESS_METADATA_KEY, InstrumentReposController.prototype[handler])).toStrictEqual({
action: 'manage',
subject: 'all'
});
}
);

it.each([
['create', 'create'],
['deleteById', 'delete'],
['findAll', 'read'],
['sync', 'update']
['sync', 'update'],
['syncAll', 'update']
] as const)('should gate %s on the %s InstrumentRepo permission', (handler, action) => {
const reflector = new Reflector();
expect(reflector.get(ROUTE_ACCESS_METADATA_KEY, InstrumentReposController.prototype[handler])).toStrictEqual({
Expand Down
Loading
Loading