feat(ext/sw): GitHost.writeFileCreateOnly() refuses to overwrite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -59,3 +59,53 @@ describe('lastCommit (GitHub)', () => {
|
|||||||
expect(await host.lastCommit('manifest.enc')).toBeNull();
|
expect(await host.lastCommit('manifest.enc')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('writeFileCreateOnly (Gitea)', () => {
|
||||||
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
|
||||||
|
|
||||||
|
it('creates when file does not exist', async () => {
|
||||||
|
fetchSpy
|
||||||
|
.mockResolvedValueOnce(new Response('', { status: 404 })) // pre-check
|
||||||
|
.mockResolvedValueOnce(new Response('{}', { status: 201 })); // POST
|
||||||
|
const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok');
|
||||||
|
await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1, 2, 3]), 'init');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect((fetchSpy.mock.calls[1][1] as RequestInit).method).toBe('POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when file already exists', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(
|
||||||
|
JSON.stringify({ sha: 'abc' }), { status: 200 },
|
||||||
|
));
|
||||||
|
const host = new GiteaHost('https://git.example.com', 'user/vault', 'tok');
|
||||||
|
await expect(
|
||||||
|
host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'),
|
||||||
|
).rejects.toThrow(/already exists/);
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1); // pre-check only, no write
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('writeFileCreateOnly (GitHub)', () => {
|
||||||
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
|
||||||
|
|
||||||
|
it('throws when file already exists', async () => {
|
||||||
|
fetchSpy.mockResolvedValueOnce(new Response(
|
||||||
|
JSON.stringify({ sha: 'abc' }), { status: 200 },
|
||||||
|
));
|
||||||
|
const host = new GitHubHost('user/vault', 'tok');
|
||||||
|
await expect(
|
||||||
|
host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init'),
|
||||||
|
).rejects.toThrow(/already exists/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates when file does not exist', async () => {
|
||||||
|
fetchSpy
|
||||||
|
.mockResolvedValueOnce(new Response('', { status: 404 }))
|
||||||
|
.mockResolvedValueOnce(new Response('{}', { status: 201 }));
|
||||||
|
const host = new GitHubHost('user/vault', 'tok');
|
||||||
|
await host.writeFileCreateOnly('manifest.enc', new Uint8Array([1]), 'init');
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export interface GitHost {
|
|||||||
/// Create or update a file in the repo with a commit message.
|
/// Create or update a file in the repo with a commit message.
|
||||||
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
|
||||||
|
|
||||||
|
/// Like writeFile, but throws if the file already exists. Used by setup
|
||||||
|
/// wizard to refuse to clobber existing vault state. Implementation must
|
||||||
|
/// pre-check existence and only POST/PUT-create — never include a sha.
|
||||||
|
writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise<void>;
|
||||||
|
|
||||||
/// Delete a file from the repo with a commit message.
|
/// Delete a file from the repo with a commit message.
|
||||||
deleteFile(path: string, message: string): Promise<void>;
|
deleteFile(path: string, message: string): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ export class GiteaHost implements GitHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise<void> {
|
||||||
|
const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
||||||
|
if (existing.ok) {
|
||||||
|
throw new Error(`writeFileCreateOnly: ${path} already exists`);
|
||||||
|
}
|
||||||
|
const b64 = uint8ArrayToBase64(content);
|
||||||
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers,
|
||||||
|
body: JSON.stringify({ content: b64, message }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`Gitea writeFileCreateOnly ${path}: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFile(path: string, message: string): Promise<void> {
|
async deleteFile(path: string, message: string): Promise<void> {
|
||||||
// Need the current SHA to delete.
|
// Need the current SHA to delete.
|
||||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||||
|
|||||||
@@ -76,6 +76,23 @@ export class GitHubHost implements GitHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async writeFileCreateOnly(path: string, content: Uint8Array, message: string): Promise<void> {
|
||||||
|
const existing = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers });
|
||||||
|
if (existing.ok) {
|
||||||
|
throw new Error(`writeFileCreateOnly: ${path} already exists`);
|
||||||
|
}
|
||||||
|
const b64 = uint8ArrayToBase64(content);
|
||||||
|
const resp = await fetch(`${this.baseUrl}/${path}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: this.headers,
|
||||||
|
body: JSON.stringify({ content: b64, message }), // no sha → create-only
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
throw new Error(`GitHub writeFileCreateOnly ${path}: ${resp.status} ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deleteFile(path: string, message: string): Promise<void> {
|
async deleteFile(path: string, message: string): Promise<void> {
|
||||||
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
const existing = await fetch(`${this.baseUrl}/${path}`, {
|
||||||
headers: this.headers,
|
headers: this.headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user