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();
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
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>;
|
||||
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user