feat(ext/sw): GitHost.writeFileCreateOnly() refuses to overwrite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-27 18:06:48 -04:00
parent 98c962796f
commit 86b5941875
4 changed files with 89 additions and 0 deletions

View File

@@ -59,3 +59,53 @@ describe('lastCommit (GitHub)', () => {
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);
});
});

View File

@@ -11,6 +11,11 @@ export interface GitHost {
/// Create or update a file in the repo with a commit message.
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.
deleteFile(path: string, message: string): Promise<void>;

View File

@@ -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> {
// Need the current SHA to delete.
const existing = await fetch(`${this.baseUrl}/${path}`, {

View File

@@ -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> {
const existing = await fetch(`${this.baseUrl}/${path}`, {
headers: this.headers,