816 lines
26 KiB
TypeScript
816 lines
26 KiB
TypeScript
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { CredentialAuditJournal, CredentialJournalError } from './audit-journal.js';
|
|
import { grantTeamRepositoryPermission, type GiteaTeamGrantProvider } from './team-grant.js';
|
|
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
import type { CredentialValidationDependencies } from './validate.js';
|
|
|
|
let cleanup: string | undefined;
|
|
afterEach(async (): Promise<void> => {
|
|
vi.restoreAllMocks();
|
|
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
|
cleanup = undefined;
|
|
});
|
|
|
|
const authority: ResolvedCredential = Object.freeze({
|
|
identity: 'provisioner',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'authority',
|
|
secret: new TextEncoder().encode('authority-canary'),
|
|
});
|
|
const subject: ResolvedCredential = Object.freeze({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'subject',
|
|
secret: new TextEncoder().encode('subject-canary'),
|
|
});
|
|
const control: ResolvedCredential = Object.freeze({
|
|
identity: 'read-control',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'control',
|
|
secret: new TextEncoder().encode('control-canary'),
|
|
});
|
|
|
|
function validation(): CredentialValidationDependencies {
|
|
return {
|
|
estateRegistry: { matches: (): boolean => true },
|
|
resolver: {
|
|
async resolve(identity: string) {
|
|
return identity === 'seat-name' ? subject : control;
|
|
},
|
|
},
|
|
provider: {
|
|
async readIdentity(resolved: ResolvedCredential) {
|
|
return {
|
|
login: resolved.identity,
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readRepositoryPermission(resolved: ResolvedCredential) {
|
|
return {
|
|
effective: resolved.identity === 'seat-name' ? 'write' : 'read',
|
|
endpoint: 'GET /api/v1/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async probeReceivePack(resolved: ResolvedCredential | undefined) {
|
|
const write = resolved?.identity === 'seat-name';
|
|
return {
|
|
state: write ? 'advertised' : 'refused',
|
|
principal: resolved?.identity ?? null,
|
|
resolutionId: resolved?.resolutionId ?? null,
|
|
contentType: write ? 'application/x-git-receive-pack-advertisement' : 'text/plain',
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('team repository grant', (): void => {
|
|
it('reads team permission, org membership, member attachment, repo attachment, and effective subject permission', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let repositoryReads = 0;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
repositoryReads += 1;
|
|
return {
|
|
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {},
|
|
async removeTeamMember(): Promise<void> {},
|
|
async attachTeamRepository(): Promise<void> {},
|
|
async detachTeamRepository(): Promise<void> {},
|
|
async readTeamMember() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('ok');
|
|
expect(result.evidence.organizationMembership?.state).toBe('present');
|
|
expect(result.evidence.teamMembership?.state).toBe('present');
|
|
expect(result.evidence.teamRepository?.state).toBe('present');
|
|
});
|
|
|
|
it('durably reports indeterminate when team-lock release cannot be verified', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let repositoryReads = 0;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
repositoryReads += 1;
|
|
return {
|
|
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {},
|
|
async removeTeamMember(): Promise<void> {},
|
|
async attachTeamRepository(): Promise<void> {},
|
|
async detachTeamRepository(): Promise<void> {},
|
|
async readTeamMember() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
const stateRoot = join(cleanup, 'state');
|
|
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{
|
|
stateRoot,
|
|
actor: 'provisioner',
|
|
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
|
throw new Error('injected close failure');
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(result.reason.code).toBe('mutation-lock-release-failed');
|
|
expect(result.mutation).toBe('applied');
|
|
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
expect(journal).toContain('"outcome":"indeterminate"');
|
|
expect(journal).toContain('"reasonCode":"mutation-lock-release-failed"');
|
|
expect(journal).toContain('"decision":"permission-write"');
|
|
});
|
|
|
|
it('never compensates a later cooperating mutation after release when final seal fails', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let repositoryReads = 0;
|
|
let memberPresent = false;
|
|
let repositoryPresent = false;
|
|
let memberRemovals = 0;
|
|
let repositoryDetachments = 0;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
repositoryReads += 1;
|
|
return {
|
|
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {
|
|
memberPresent = true;
|
|
},
|
|
async removeTeamMember(): Promise<void> {
|
|
memberRemovals += 1;
|
|
memberPresent = false;
|
|
},
|
|
async attachTeamRepository(): Promise<void> {
|
|
repositoryPresent = true;
|
|
},
|
|
async detachTeamRepository(): Promise<void> {
|
|
repositoryDetachments += 1;
|
|
repositoryPresent = false;
|
|
},
|
|
async readTeamMember() {
|
|
return {
|
|
state: memberPresent ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: repositoryPresent ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
const originalSeal = CredentialAuditJournal.prototype.seal;
|
|
vi.spyOn(CredentialAuditJournal.prototype, 'seal').mockImplementation(async function (
|
|
this: CredentialAuditJournal,
|
|
outcome,
|
|
reasonCode,
|
|
): Promise<string> {
|
|
if (outcome === 'ok') {
|
|
throw new CredentialJournalError('journal-unavailable', 'injected final seal failure');
|
|
}
|
|
return originalSeal.call(this, outcome, reasonCode);
|
|
});
|
|
|
|
await expect(
|
|
grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{
|
|
stateRoot: join(cleanup, 'state'),
|
|
actor: 'provisioner',
|
|
acquireTeamLock: async (): Promise<() => Promise<void>> => async (): Promise<void> => {
|
|
// Simulate the next cooperating owner establishing the same state
|
|
// immediately after acquiring the released lock.
|
|
memberPresent = true;
|
|
repositoryPresent = true;
|
|
},
|
|
},
|
|
),
|
|
).rejects.toMatchObject({ code: 'journal-unavailable', mutation: 'applied' });
|
|
expect(memberRemovals).toBe(0);
|
|
expect(repositoryDetachments).toBe(0);
|
|
expect(memberPresent).toBe(true);
|
|
expect(repositoryPresent).toBe(true);
|
|
});
|
|
|
|
it('refuses a shared team already attached to any repository outside the request', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let mutated = false;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
return {
|
|
repositories: ['owner/unrelated'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {
|
|
mutated = true;
|
|
},
|
|
async removeTeamMember(): Promise<void> {
|
|
mutated = true;
|
|
},
|
|
async attachTeamRepository(): Promise<void> {
|
|
mutated = true;
|
|
},
|
|
async detachTeamRepository(): Promise<void> {
|
|
mutated = true;
|
|
},
|
|
async readTeamMember() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('refused');
|
|
expect(result.reason.code).toBe('team-scope-exceeds-request');
|
|
expect(mutated).toBe(false);
|
|
});
|
|
|
|
it('journals absent team objects as absent rather than present', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let repositoryReads = 0;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
repositoryReads += 1;
|
|
return {
|
|
repositories: repositoryReads === 1 ? [] : ['owner/repo'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {},
|
|
async removeTeamMember(): Promise<void> {},
|
|
async attachTeamRepository(): Promise<void> {},
|
|
async detachTeamRepository(): Promise<void> {},
|
|
async readTeamMember() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const stateRoot = join(cleanup, 'state');
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{ stateRoot, actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('indeterminate');
|
|
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
expect(journal).toContain('team-member-absent');
|
|
expect(journal).toContain('team-repository-absent');
|
|
expect(journal).not.toContain('"decision":"team-member-present"');
|
|
expect(journal).not.toContain('"decision":"team-repository-present"');
|
|
});
|
|
|
|
it('fails closed and removes newly added membership when team scope changes during mutation', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let repositoryReads = 0;
|
|
let membershipReads = 0;
|
|
let removed = false;
|
|
let detached = false;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
repositoryReads += 1;
|
|
return {
|
|
repositories: repositoryReads === 1 ? [] : ['owner/repo', 'owner/concurrent-attachment'],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {},
|
|
async removeTeamMember(): Promise<void> {
|
|
removed = true;
|
|
},
|
|
async attachTeamRepository(): Promise<void> {},
|
|
async detachTeamRepository(): Promise<void> {
|
|
detached = true;
|
|
},
|
|
async readTeamMember() {
|
|
membershipReads += 1;
|
|
return {
|
|
state: membershipReads === 1 || removed ? 'absent' : 'present',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: detached ? 'absent' : 'present',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const stateRoot = join(cleanup, 'state');
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{ stateRoot, actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(result.reason.code).toBe('team-scope-changed-during-grant');
|
|
expect(result.evidence.teamRepositorySet?.repositories).toEqual([
|
|
'owner/repo',
|
|
'owner/concurrent-attachment',
|
|
]);
|
|
expect(removed).toBe(true);
|
|
expect(detached).toBe(true);
|
|
const [journalName] = await readdir(join(stateRoot, 'journals'));
|
|
const journal = await readFile(join(stateRoot, 'journals', journalName!), 'utf8');
|
|
expect(journal.indexOf('team-member-absent')).toBeLessThan(
|
|
journal.indexOf('team-member-applied'),
|
|
);
|
|
expect(journal).toContain('team-repository-rollback-applied');
|
|
expect(journal).toContain('team-repository-absent');
|
|
});
|
|
|
|
it('compensates provider changes when repository attachment fails after applying', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let memberPresent = false;
|
|
let repositoryPresent = false;
|
|
let memberRemoved = false;
|
|
let repositoryDetached = false;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
return {
|
|
repositories: repositoryPresent ? ['owner/repo'] : [],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {
|
|
memberPresent = true;
|
|
},
|
|
async removeTeamMember(): Promise<void> {
|
|
memberPresent = false;
|
|
memberRemoved = true;
|
|
},
|
|
async attachTeamRepository(): Promise<void> {
|
|
repositoryPresent = true;
|
|
throw new Error('provider response lost after attachment');
|
|
},
|
|
async detachTeamRepository(): Promise<void> {
|
|
repositoryPresent = false;
|
|
repositoryDetached = true;
|
|
},
|
|
async readTeamMember() {
|
|
return {
|
|
state: memberPresent ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: repositoryPresent ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
|
|
const result = await grantTeamRepositoryPermission(
|
|
{
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write',
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
},
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
{ stateRoot: join(cleanup, 'state'), actor: 'provisioner' },
|
|
);
|
|
|
|
expect(result.outcome).toBe('indeterminate');
|
|
expect(memberPresent).toBe(false);
|
|
expect(repositoryPresent).toBe(false);
|
|
expect(memberRemoved).toBe(true);
|
|
expect(repositoryDetached).toBe(true);
|
|
});
|
|
|
|
it('refuses a second governed mutation while the same team lock is held', async (): Promise<void> => {
|
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-team-grant-'));
|
|
let releaseFirst!: () => void;
|
|
const firstMayFinish = new Promise<void>((resolve): void => {
|
|
releaseFirst = resolve;
|
|
});
|
|
let markFirstEntered!: () => void;
|
|
const firstEntered = new Promise<void>((resolve): void => {
|
|
markFirstEntered = resolve;
|
|
});
|
|
let addCalls = 0;
|
|
let memberAdded = false;
|
|
let repositoryAttached = false;
|
|
const provider: GiteaTeamGrantProvider = {
|
|
async readBasicIdentity() {
|
|
return {
|
|
login: 'provisioner',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async resolveTeam() {
|
|
return {
|
|
id: 7,
|
|
name: 'writers',
|
|
permission: 'write',
|
|
endpoint: 'GET /api/v1/orgs/owner/teams',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async listTeamRepositories() {
|
|
return {
|
|
repositories: repositoryAttached ? ['owner/repo'] : [],
|
|
endpoint: 'GET /api/v1/teams/7/repos',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async addTeamMember(): Promise<void> {
|
|
addCalls += 1;
|
|
markFirstEntered();
|
|
await firstMayFinish;
|
|
memberAdded = true;
|
|
},
|
|
async removeTeamMember(): Promise<void> {
|
|
memberAdded = false;
|
|
},
|
|
async attachTeamRepository(): Promise<void> {
|
|
repositoryAttached = true;
|
|
},
|
|
async detachTeamRepository(): Promise<void> {
|
|
repositoryAttached = false;
|
|
},
|
|
async readTeamMember() {
|
|
return {
|
|
state: memberAdded ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/members/seat-name',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readTeamRepository() {
|
|
return {
|
|
state: repositoryAttached ? 'present' : 'absent',
|
|
endpoint: 'GET /api/v1/teams/7/repos/owner/repo',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
async readOrganizationMembership() {
|
|
return {
|
|
state: 'present',
|
|
endpoint: 'GET /api/v1/users/seat-name/orgs',
|
|
contentType: 'application/json',
|
|
};
|
|
},
|
|
};
|
|
const request = {
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
repo: 'owner/repo',
|
|
permission: 'write' as const,
|
|
team: 'writers',
|
|
readOnlyControlIdentity: 'read-control',
|
|
};
|
|
const options = { stateRoot: join(cleanup, 'state-one'), actor: 'provisioner' };
|
|
const secondOptions = { stateRoot: join(cleanup, 'state-two'), actor: 'provisioner' };
|
|
|
|
const first = grantTeamRepositoryPermission(
|
|
request,
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
options,
|
|
);
|
|
await firstEntered;
|
|
const second = await grantTeamRepositoryPermission(
|
|
request,
|
|
authority,
|
|
provider,
|
|
validation(),
|
|
secondOptions,
|
|
);
|
|
releaseFirst();
|
|
const completedFirst = await first;
|
|
|
|
expect(completedFirst.outcome).toBe('ok');
|
|
expect(second.outcome).toBe('indeterminate');
|
|
expect(second.reason.code).toBe('concurrent-mutation');
|
|
expect(second.mutation).toBe('none');
|
|
expect(addCalls).toBe(1);
|
|
});
|
|
});
|