Files
stack/packages/mosaic/src/credentials/team-grant.spec.ts
T
2026-08-05 19:52:33 -05:00

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