208 lines
7.7 KiB
TypeScript
208 lines
7.7 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { GiteaCredentialProviderAdapter, GiteaTeamGrantProviderAdapter } from './gitea-provider.js';
|
|
import type { ResolvedCredential } from './credential-provider.dto.js';
|
|
|
|
const credential: ResolvedCredential = Object.freeze({
|
|
identity: 'seat-name',
|
|
estate: 'homelab',
|
|
host: 'git.example.invalid',
|
|
resolutionId: 'resolution-1',
|
|
secret: new TextEncoder().encode('seeded-secret-canary'),
|
|
});
|
|
|
|
function jsonResponse(body: object, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json;charset=utf-8' },
|
|
});
|
|
}
|
|
|
|
describe('Gitea credential provider transport', (): void => {
|
|
it('reads the provider identity with the fixed transport and no secret in the URL', async (): Promise<void> => {
|
|
const calls: Array<{ readonly input: string; readonly init?: RequestInit }> = [];
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
|
|
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
},
|
|
);
|
|
|
|
const evidence = await adapter.readIdentity(credential);
|
|
|
|
expect(evidence).toEqual({
|
|
login: 'seat-name',
|
|
endpoint: 'GET /api/v1/user',
|
|
contentType: 'application/json;charset=utf-8',
|
|
});
|
|
expect(calls[0]?.input).toBe('https://git.example.invalid/api/v1/user');
|
|
expect(calls[0]?.input).not.toContain('seeded-secret-canary');
|
|
expect(new Headers(calls[0]?.init?.headers).get('user-agent')).toBe('mosaic-cred/1');
|
|
});
|
|
|
|
it('maps the authenticated provider repository object to effective permission', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> =>
|
|
jsonResponse({
|
|
id: 99,
|
|
full_name: 'owner/repo',
|
|
permissions: { admin: false, push: true, pull: true },
|
|
}),
|
|
);
|
|
|
|
const evidence = await adapter.readRepositoryPermission(credential, 'owner/repo');
|
|
|
|
expect(evidence.effective).toBe('write');
|
|
expect(evidence.endpoint).toBe('GET /api/v1/repos/owner/repo');
|
|
});
|
|
|
|
it('binds an authenticated receive-pack advertisement to the supplied credential handle', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> =>
|
|
new Response('001f# service=git-receive-pack\n0000', {
|
|
status: 200,
|
|
headers: {
|
|
'content-type': 'application/x-git-receive-pack-advertisement',
|
|
},
|
|
}),
|
|
);
|
|
|
|
const evidence = await adapter.probeReceivePack(credential, 'owner/repo');
|
|
|
|
expect(evidence).toEqual({
|
|
state: 'advertised',
|
|
principal: 'seat-name',
|
|
resolutionId: 'resolution-1',
|
|
contentType: 'application/x-git-receive-pack-advertisement',
|
|
});
|
|
});
|
|
|
|
it('reports authenticated and unauthenticated receive-pack refusals without inventing success', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> =>
|
|
new Response('denied', { status: 403, headers: { 'content-type': 'text/plain' } }),
|
|
);
|
|
|
|
await expect(adapter.probeReceivePack(credential, 'owner/repo')).resolves.toMatchObject({
|
|
state: 'refused',
|
|
principal: 'seat-name',
|
|
resolutionId: 'resolution-1',
|
|
});
|
|
await expect(adapter.probeReceivePack(undefined, 'owner/repo')).resolves.toMatchObject({
|
|
state: 'refused',
|
|
principal: null,
|
|
resolutionId: null,
|
|
});
|
|
});
|
|
|
|
it('does not call a scope-forbidden identity read a dead credential', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> => jsonResponse({ message: 'forbidden' }, 403),
|
|
);
|
|
|
|
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
code: 'identity-read-forbidden',
|
|
});
|
|
});
|
|
|
|
it('classifies only the supplied credential as rejected without inferring identity absence', async (): Promise<void> => {
|
|
let calls = 0;
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> => {
|
|
calls += 1;
|
|
return jsonResponse({ message: 'unauthorized' }, 401);
|
|
},
|
|
);
|
|
|
|
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
code: 'credential-rejected',
|
|
});
|
|
expect(calls).toBe(1);
|
|
});
|
|
|
|
it('classifies a rejected credential separately when the declared identity exists', async (): Promise<void> => {
|
|
let call = 0;
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> => {
|
|
call += 1;
|
|
if (call === 1) return jsonResponse({ message: 'unauthorized' }, 401);
|
|
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
},
|
|
);
|
|
|
|
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
code: 'credential-rejected',
|
|
});
|
|
});
|
|
|
|
it('rejects a 200 HTML identity response as unexpected content type', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> =>
|
|
new Response('<html>not an API object</html>', {
|
|
status: 200,
|
|
headers: { 'content-type': 'text/html' },
|
|
}),
|
|
);
|
|
|
|
await expect(adapter.readIdentity(credential)).rejects.toMatchObject({
|
|
code: 'unexpected-content-type',
|
|
});
|
|
});
|
|
|
|
it('reads team permission, member attachment, and repository attachment separately', async (): Promise<void> => {
|
|
const adapter = new GiteaTeamGrantProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
|
const url = String(input);
|
|
if (url.endsWith('/api/v1/orgs/owner/teams')) {
|
|
return jsonResponse([{ id: 7, name: 'writers', permission: 'write' }]);
|
|
}
|
|
if (init?.method === 'PUT') return new Response(null, { status: 204 });
|
|
if (url.includes('/members/seat-name')) {
|
|
return jsonResponse({ id: 21, login: 'seat-name' });
|
|
}
|
|
if (url.includes('/repos/owner/repo')) {
|
|
return jsonResponse({ id: 4, full_name: 'owner/repo' });
|
|
}
|
|
return jsonResponse({ message: 'unexpected' }, 500);
|
|
},
|
|
);
|
|
|
|
const team = await adapter.resolveTeam(credential, 'owner', 'writers');
|
|
await adapter.addTeamMember(credential, team.id, 'seat-name');
|
|
await adapter.attachTeamRepository(credential, team.id, 'owner/repo');
|
|
await expect(adapter.readTeamMember(credential, team.id, 'seat-name')).resolves.toMatchObject({
|
|
state: 'present',
|
|
});
|
|
await expect(
|
|
adapter.readTeamRepository(credential, team.id, 'owner/repo'),
|
|
).resolves.toMatchObject({ state: 'present' });
|
|
expect(team).toMatchObject({ id: 7, name: 'writers', permission: 'write' });
|
|
});
|
|
|
|
it('never includes seeded secret material in provider error messages', async (): Promise<void> => {
|
|
const adapter = new GiteaCredentialProviderAdapter(
|
|
'https://git.example.invalid',
|
|
async (): Promise<Response> => {
|
|
throw new Error('connection reset');
|
|
},
|
|
);
|
|
|
|
let message = '';
|
|
try {
|
|
await adapter.readIdentity(credential);
|
|
} catch (error: unknown) {
|
|
message = error instanceof Error ? error.message : String(error);
|
|
}
|
|
expect(message).not.toContain('seeded-secret-canary');
|
|
expect(message).toContain('provider-unavailable');
|
|
});
|
|
});
|