feat(webdav): add comprehensive Last-Modified fallback support

- Implements robust WebDAV sync for servers without ETag support
- Adds _extractValidators method for ETag and Last-Modified headers
- Enhances conditional header creation with Last-Modified support
- Adds LOCK/UNLOCK mechanism for safe resource creation
- Implements server capability detection with caching
- Adds NoRevAPIError retry mechanism for missing validators
- Includes comprehensive test coverage for all workflows
- Maintains backward compatibility with existing ETag-based code

This enables WebDAV sync with basic servers that only support
Last-Modified headers while preserving optimal performance
for servers with full ETag support.
This commit is contained in:
Johannes Millan 2025-07-12 23:10:43 +02:00
parent 0ba66bc266
commit 8238606f46
10 changed files with 1675 additions and 48 deletions

46
debug-headers.spec.ts Normal file
View file

@ -0,0 +1,46 @@
import { WebdavApi } from './src/app/pfapi/api/sync/providers/webdav/webdav-api';
import { WebdavPrivateCfg } from './src/app/pfapi/api/sync/providers/webdav/webdav';
import { createMockResponse } from './src/app/pfapi/api/sync/providers/webdav/webdav-api-test-utils';
describe('Debug Headers', () => {
let mockFetch: jasmine.Spy;
let api: WebdavApi;
const mockConfig: WebdavPrivateCfg = {
baseUrl: 'https://webdav.example.com',
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
serverCapabilities: {
supportsETags: true,
supportsLastModified: false,
supportsIfHeader: true,
supportsLocking: false,
},
};
beforeEach(() => {
mockFetch = spyOn(globalThis, 'fetch');
api = new WebdavApi(async () => mockConfig);
});
it('should set If-None-Match for new file creation', async () => {
const uploadResponse = createMockResponse(201, {
etag: '"v1"',
});
mockFetch.and.callFake((url, options) => {
console.log('Called with:', url, JSON.stringify(options));
return Promise.resolve(uploadResponse);
});
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('v1');
expect(mockFetch).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,46 @@
import { WebdavApi } from './webdav-api';
import { WebdavPrivateCfg } from './webdav';
import { createMockResponse } from './webdav-api-test-utils';
describe('Debug Headers', () => {
let mockFetch: jasmine.Spy;
let api: WebdavApi;
const mockConfig: WebdavPrivateCfg = {
baseUrl: 'https://webdav.example.com',
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
serverCapabilities: {
supportsETags: true,
supportsLastModified: false,
supportsIfHeader: true,
supportsLocking: false,
},
};
beforeEach(() => {
mockFetch = spyOn(globalThis, 'fetch');
api = new WebdavApi(async () => mockConfig);
});
it('should set If-None-Match for new file creation', async () => {
const uploadResponse = createMockResponse(201, {
etag: '"v1"',
});
mockFetch.and.callFake((url, options) => {
console.log('Called with:', url, JSON.stringify(options));
return Promise.resolve(uploadResponse);
});
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('v1');
expect(mockFetch).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,295 @@
import { WebdavApi } from './webdav-api';
import {
WebdavPrivateCfg,
WebdavServerType,
getRecommendedServerCapabilities,
} from './webdav';
import { createMockResponse } from './webdav-api-test-utils';
/* eslint-disable @typescript-eslint/naming-convention */
describe('WebdavApi Configuration Options', () => {
let mockFetch: jasmine.Spy;
let api: WebdavApi;
const baseConfig: WebdavPrivateCfg = {
baseUrl: 'https://webdav.example.com',
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
};
beforeEach(() => {
mockFetch = spyOn(globalThis, 'fetch');
});
describe('basicCompatibilityMode', () => {
it('should upload without conditional headers in basic compatibility mode', async () => {
const configWithBasicMode: WebdavPrivateCfg = {
...baseConfig,
basicCompatibilityMode: true,
};
api = new WebdavApi(async () => configWithBasicMode);
const uploadResponse = createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(mockFetch).toHaveBeenCalledWith(
jasmine.any(String),
jasmine.objectContaining({
method: 'PUT',
headers: jasmine.objectContaining({
'Content-Type': 'application/octet-stream',
'Content-Length': '7',
}),
}),
);
// Should not have any conditional headers
const headers = mockFetch.calls.mostRecent().args[1]?.headers || {};
expect(headers['If-Match']).toBeUndefined();
expect(headers['If-None-Match']).toBeUndefined();
expect(headers['If-Unmodified-Since']).toBeUndefined();
expect(headers['If']).toBeUndefined();
});
});
describe('preferLastModified option', () => {
it('should prefer Last-Modified over ETag when preferLastModified is true', async () => {
const configWithPreference: WebdavPrivateCfg = {
...baseConfig,
preferLastModified: true,
serverCapabilities: {
supportsETags: true,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: true,
},
};
api = new WebdavApi(async () => configWithPreference);
const uploadResponse = createMockResponse(201, {
etag: '"v1"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
// Should return Last-Modified even though ETag is available
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
});
});
describe('maxRetries configuration', () => {
it('should respect custom maxRetries setting', async () => {
const configWithRetries: WebdavPrivateCfg = {
...baseConfig,
maxRetries: 1, // Set to 1 retry only
};
api = new WebdavApi(async () => configWithRetries);
// Mock detection that triggers retry
const detectionResponse = createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/sync/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Wed, 21 Oct 2015 07:28:00 GMT</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
);
// First upload fails with no validator
const firstUploadResponse = createMockResponse(201, {});
// Second upload also fails
const secondUploadResponse = createMockResponse(201, {});
let uploadAttempts = 0;
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
uploadAttempts++;
return Promise.resolve(
uploadAttempts === 1 ? firstUploadResponse : secondUploadResponse,
);
} else if (method === 'PROPFIND') {
if (url.includes('/test.txt')) {
return Promise.reject(new Error('Not found'));
} else {
return Promise.resolve(detectionResponse);
}
} else if (method === 'GET') {
return Promise.reject(new Error('Not found'));
}
throw new Error(`Unexpected call: ${method}`);
});
// Should fail after maxRetries attempts
await expectAsync(
api.upload({
path: '/test.txt',
data: 'content',
}),
).toBeRejected();
// Should have attempted exactly 2 uploads (original + 1 retry)
expect(uploadAttempts).toBe(2);
});
});
describe('getRecommendedServerCapabilities helper', () => {
it('should return correct capabilities for Nextcloud', () => {
const capabilities = getRecommendedServerCapabilities(WebdavServerType.NEXTCLOUD);
expect(capabilities).toEqual({
supportsETags: true,
supportsIfHeader: true,
supportsLocking: true,
supportsLastModified: true,
});
});
it('should return correct capabilities for Apache mod_dav', () => {
const capabilities = getRecommendedServerCapabilities(
WebdavServerType.APACHE_MOD_DAV,
);
expect(capabilities).toEqual({
supportsETags: true,
supportsIfHeader: false,
supportsLocking: true,
supportsLastModified: true,
});
});
it('should return correct capabilities for nginx dav', () => {
const capabilities = getRecommendedServerCapabilities(WebdavServerType.NGINX_DAV);
expect(capabilities).toEqual({
supportsETags: false,
supportsIfHeader: false,
supportsLocking: false,
supportsLastModified: true,
});
});
it('should return correct capabilities for basic WebDAV', () => {
const capabilities = getRecommendedServerCapabilities(
WebdavServerType.BASIC_WEBDAV,
);
expect(capabilities).toEqual({
supportsETags: false,
supportsIfHeader: false,
supportsLocking: false,
supportsLastModified: true,
});
});
});
describe('Real-world configuration scenarios', () => {
it('should work with Nextcloud recommended settings', async () => {
const nextcloudConfig: WebdavPrivateCfg = {
...baseConfig,
serverCapabilities: getRecommendedServerCapabilities(WebdavServerType.NEXTCLOUD),
};
api = new WebdavApi(async () => nextcloudConfig);
const uploadResponse = createMockResponse(201, {
etag: '"abc123"',
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('abc123'); // Should prefer ETag
});
it('should work with nginx dav recommended settings', async () => {
const nginxConfig: WebdavPrivateCfg = {
...baseConfig,
serverCapabilities: getRecommendedServerCapabilities(WebdavServerType.NGINX_DAV),
};
api = new WebdavApi(async () => nginxConfig);
const uploadResponse = createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT'); // Should use Last-Modified
});
it('should handle basic WebDAV server with minimal features', async () => {
const basicConfig: WebdavPrivateCfg = {
...baseConfig,
basicCompatibilityMode: true,
serverCapabilities: getRecommendedServerCapabilities(
WebdavServerType.BASIC_WEBDAV,
),
};
api = new WebdavApi(async () => basicConfig);
const uploadResponse = createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
const result = await api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
// Should not use any conditional headers in basic mode
const headers = mockFetch.calls.mostRecent().args[1]?.headers || {};
expect(headers['If-Match']).toBeUndefined();
expect(headers['If-None-Match']).toBeUndefined();
});
});
});

View file

@ -0,0 +1,436 @@
import { WebdavApi } from './webdav-api';
import { WebdavPrivateCfg } from './webdav';
import { createMockResponse } from './webdav-api-test-utils';
import {
NoRevAPIError,
NoEtagAPIError,
FileExistsAPIError,
RemoteFileNotFoundAPIError,
} from '../../../errors/errors';
/* eslint-disable @typescript-eslint/naming-convention */
describe('WebdavApi Enhanced Error Handling', () => {
let mockFetch: jasmine.Spy;
let api: WebdavApi;
const mockConfig: WebdavPrivateCfg = {
baseUrl: 'https://webdav.example.com',
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
};
beforeEach(() => {
mockFetch = spyOn(globalThis, 'fetch');
api = new WebdavApi(async () => mockConfig);
});
describe('NoRevAPIError vs NoEtagAPIError distinction', () => {
it('should throw NoRevAPIError when server supports Last-Modified but no validator is found', async () => {
// Configure server with Last-Modified support only
const configWithLastModified: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: false,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: false,
},
};
const apiWithLastModified = new WebdavApi(async () => configWithLastModified);
// Upload succeeds but returns no Last-Modified header
const uploadResponse = createMockResponse(201, {});
// PROPFIND fails
const propfindError = new RemoteFileNotFoundAPIError('/test.txt');
// GET fails
const getError = new RemoteFileNotFoundAPIError('/test.txt');
let callCount = 0;
let putCount = 0;
mockFetch.and.callFake((url, options) => {
callCount++;
const method = options?.method || 'GET';
if (method === 'PUT') {
putCount++;
if (putCount === 1) {
return Promise.resolve(uploadResponse);
}
// Should not have a second PUT since capabilities are pre-configured
throw new Error(`Unexpected second PUT call at call ${callCount}`);
} else if (method === 'PROPFIND') {
return Promise.reject(propfindError);
} else if (method === 'GET') {
return Promise.reject(getError);
}
throw new Error(`Unexpected call: ${method} at call ${callCount}`);
});
await expectAsync(
apiWithLastModified.upload({ path: '/test.txt', data: 'content' }),
).toBeRejectedWith(jasmine.any(NoRevAPIError));
});
it('should throw NoEtagAPIError when server supports ETags but no ETag is found', async () => {
// Configure server with ETag support
const configWithEtag: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: true,
supportsLastModified: false,
supportsIfHeader: false,
supportsLocking: false,
},
};
const apiWithEtag = new WebdavApi(async () => configWithEtag);
// Upload succeeds but returns no ETag header
const uploadResponse = createMockResponse(201, {});
// PROPFIND fails
const propfindError = new RemoteFileNotFoundAPIError('/test.txt');
// GET fails
const getError = new RemoteFileNotFoundAPIError('/test.txt');
let callCount = 0;
mockFetch.and.callFake((url, options) => {
callCount++;
const method = options?.method || 'GET';
if (method === 'PUT' && callCount === 1) {
return Promise.resolve(uploadResponse);
} else if (method === 'PROPFIND') {
return Promise.reject(propfindError);
} else if (method === 'GET') {
return Promise.reject(getError);
}
throw new Error(`Unexpected call: ${method} at call ${callCount}`);
});
await expectAsync(
apiWithEtag.upload({ path: '/test.txt', data: 'content' }),
).toBeRejectedWith(jasmine.any(NoEtagAPIError));
});
});
describe('Error messages with context', () => {
it('should include server capabilities in error when no safe creation method available', async () => {
const configNoSafeCreation: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: false,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: false,
},
};
const apiNoSafeCreation = new WebdavApi(async () => configNoSafeCreation);
// Spy on console.warn to capture the warning
const warnSpy = spyOn(console, 'warn');
const uploadResponse = createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
mockFetch.and.returnValue(Promise.resolve(uploadResponse));
await apiNoSafeCreation.upload({
path: '/newfile.txt',
data: 'content',
isOverwrite: false,
});
expect(warnSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/WARNING: No safe creation method available/),
jasmine.objectContaining({
supportsETags: false,
supportsIfHeader: false,
supportsLocking: false,
}),
);
});
it('should provide detailed error for 412 Precondition Failed with expected validator', async () => {
const preconditionFailedResponse = createMockResponse(412);
mockFetch.and.returnValue(Promise.resolve(preconditionFailedResponse));
await expectAsync(
api.upload({
path: '/test.txt',
data: 'content',
expectedEtag: 'old-etag',
}),
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/file was modified.*old-etag/),
}),
);
});
it('should throw FileExistsAPIError for 412 without expected validator', async () => {
const preconditionFailedResponse = createMockResponse(412);
mockFetch.and.returnValue(Promise.resolve(preconditionFailedResponse));
await expectAsync(
api.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
}),
).toBeRejectedWith(jasmine.any(FileExistsAPIError));
});
});
describe('Lock error handling', () => {
it('should handle 423 Locked errors with descriptive message', async () => {
const lockedResponse = createMockResponse(423);
mockFetch.and.returnValue(Promise.resolve(lockedResponse));
await expectAsync(
api.upload({ path: '/locked.txt', data: 'content' }),
).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/Resource is locked/),
}),
);
});
it('should continue upload even if lock acquisition fails', async () => {
const configWithLocking: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: false,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: true,
},
};
const apiWithLocking = new WebdavApi(async () => configWithLocking);
// Mock console.error to check for lock failure log
const errorSpy = spyOn(console, 'error');
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'LOCK') {
// Lock fails
return Promise.reject(new Error('Lock failed'));
} else if (method === 'PUT') {
// Upload succeeds anyway
return Promise.resolve(
createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
}),
);
}
throw new Error(`Unexpected call: ${method}`);
});
const result = await apiWithLocking.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
// Check that lock failure was logged
expect(errorSpy).toHaveBeenCalledWith(
jasmine.stringMatching(/Failed to acquire lock for safe creation/),
jasmine.any(Object),
);
});
});
describe('Fallback chain error reporting', () => {
it('should report all attempted methods when validator retrieval fails', async () => {
// Upload succeeds but returns no validator
const uploadResponse = createMockResponse(201, {});
// All fallback methods fail
let putCount = 0;
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
putCount++;
if (putCount === 1) {
return Promise.resolve(uploadResponse);
}
} else if (method === 'PROPFIND') {
// First PROPFIND might be for capability detection on root
if (url.includes('/test.txt')) {
return Promise.reject(new Error('PROPFIND failed'));
} else {
// Capability detection PROPFIND - return minimal response
return Promise.resolve(
createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/</d:href>
<d:propstat>
<d:prop></d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
),
);
}
} else if (method === 'GET') {
return Promise.reject(new Error('GET failed'));
}
throw new Error(`Unexpected call: ${method}`);
});
try {
await api.upload({ path: '/test.txt', data: 'content' });
fail('Expected NoEtagAPIError');
} catch (error: any) {
expect(error).toEqual(jasmine.any(NoEtagAPIError));
expect(error.additionalLog).toEqual(
jasmine.objectContaining({
attemptedMethods: ['response-headers', 'PROPFIND', 'GET'],
}),
);
}
});
});
describe('Download error scenarios', () => {
it('should handle 304 Not Modified gracefully', async () => {
const notModifiedResponse = createMockResponse(304, {
etag: '"unchanged"',
});
mockFetch.and.returnValue(Promise.resolve(notModifiedResponse));
await expectAsync(
api.download({ path: '/test.txt', localRev: 'unchanged' }),
).toBeRejectedWith(
jasmine.objectContaining({
status: 304,
localRev: 'unchanged',
message: jasmine.stringMatching(/not modified/i),
}),
);
});
it('should handle HTML error responses appropriately', async () => {
const htmlError = '<html><body>404 Not Found</body></html>';
const htmlResponse = createMockResponse(
200,
{
'content-type': 'text/html',
},
htmlError,
);
mockFetch.and.returnValue(Promise.resolve(htmlResponse));
await expectAsync(api.download({ path: '/test.txt' })).toBeRejectedWith(
jasmine.any(RemoteFileNotFoundAPIError),
);
});
});
describe('Delete operation error handling', () => {
it('should provide clear error for non-empty directory deletion', async () => {
const conflictResponse = createMockResponse(409);
mockFetch.and.returnValue(Promise.resolve(conflictResponse));
await expectAsync(api.remove('/folder/', 'folder-etag')).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/non-empty directory/),
}),
);
});
it('should handle 424 Failed Dependency appropriately', async () => {
const failedDepResponse = createMockResponse(424);
mockFetch.and.returnValue(Promise.resolve(failedDepResponse));
await expectAsync(api.remove('/dependent-file.txt')).toBeRejectedWith(
jasmine.objectContaining({
message: jasmine.stringMatching(/failed due to dependency/),
}),
);
});
});
describe('Retry mechanism improvements', () => {
it('should detect capabilities and retry when NoRevAPIError occurs', async () => {
// Initial upload returns no validator
const uploadResponse = createMockResponse(201, {});
// Capability detection response
const propfindResponse = createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/sync/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Wed, 21 Oct 2015 07:28:00 GMT</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
);
// Retry succeeds with Last-Modified
const retryResponse = createMockResponse(201, {
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
});
let uploadAttempts = 0;
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
uploadAttempts++;
if (uploadAttempts === 1) {
return Promise.resolve(uploadResponse);
} else {
return Promise.resolve(retryResponse);
}
} else if (method === 'PROPFIND') {
if (url.includes('/test.txt')) {
return Promise.reject(new RemoteFileNotFoundAPIError('/test.txt'));
} else {
return Promise.resolve(propfindResponse);
}
} else if (method === 'GET') {
return Promise.reject(new RemoteFileNotFoundAPIError('/test.txt'));
}
throw new Error(`Unexpected call: ${method}`);
});
const result = await api.upload({ path: '/test.txt', data: 'content' });
expect(result).toBe('Wed, 21 Oct 2015 07:28:00 GMT');
expect(uploadAttempts).toBe(2);
});
});
});

View file

@ -14,6 +14,13 @@ describe('WebdavApi - Fallback Paths', () => {
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
// Pre-configure server capabilities to prevent detection calls
serverCapabilities: {
supportsETags: true,
supportsIfHeader: true,
supportsLocking: false,
supportsLastModified: false,
},
};
const createMockResponse = (

View file

@ -0,0 +1,557 @@
import { WebdavApi } from './webdav-api';
import { WebdavPrivateCfg } from './webdav';
import { createMockResponse } from './webdav-api-test-utils';
import { RemoteFileNotFoundAPIError, NoEtagAPIError } from '../../../errors/errors';
/* eslint-disable @typescript-eslint/naming-convention */
describe('WebdavApi Integration Tests - Last-Modified Fallbacks', () => {
let mockFetch: jasmine.Spy;
let api: WebdavApi;
const mockConfig: WebdavPrivateCfg = {
baseUrl: 'https://webdav.example.com',
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
};
beforeEach(() => {
mockFetch = spyOn(globalThis, 'fetch');
api = new WebdavApi(async () => mockConfig);
});
describe('Full workflow with ETag-only server', () => {
it('should complete full sync workflow with ETag support', async () => {
// Configure server with ETag support only
const configWithEtag: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: true,
supportsLastModified: false,
supportsIfHeader: true,
supportsLocking: false,
},
};
const apiWithEtag = new WebdavApi(async () => configWithEtag);
// Mock responses
const uploadResponse = createMockResponse(201, {
etag: '"v1"',
});
const downloadResponse = createMockResponse(
200,
{
etag: '"v1"',
},
'file content v1',
);
const updateResponse = createMockResponse(200, {
etag: '"v2"',
});
const deleteResponse = createMockResponse(204);
let callCount = 0;
mockFetch.and.callFake((url, options) => {
callCount++;
const method = options?.method || 'GET';
if (method === 'PUT' && callCount === 1) {
// Initial upload for new file creation
return Promise.resolve(uploadResponse);
} else if (method === 'GET' && callCount === 2) {
// Download
return Promise.resolve(downloadResponse);
} else if (method === 'PUT' && callCount === 3) {
// Update - conditional update
return Promise.resolve(updateResponse);
} else if (method === 'PROPFIND' && callCount === 4) {
// getFileMeta call before delete to check resource type
return Promise.resolve(
createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/test.txt</d:href>
<d:propstat>
<d:prop>
<d:getetag>"v2"</d:getetag>
<d:resourcetype/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
),
);
} else if (method === 'DELETE' && callCount === 5) {
// Delete with conditional headers
return Promise.resolve(deleteResponse);
}
throw new Error(`Unexpected call: ${method} at call ${callCount}`);
});
// 1. Create new file
const createResult = await apiWithEtag.upload({
path: '/test.txt',
data: 'file content v1',
isOverwrite: false,
});
expect(createResult).toBe('v1');
// 2. Download file
const downloadResult = await apiWithEtag.download({ path: '/test.txt' });
expect(downloadResult.rev).toBe('v1');
expect(downloadResult.dataStr).toBe('file content v1');
// 3. Update file
const updateResult = await apiWithEtag.upload({
path: '/test.txt',
data: 'file content v2',
expectedEtag: 'v1',
});
expect(updateResult).toBe('v2');
// 4. Delete file
await apiWithEtag.remove('/test.txt', 'v2');
expect(callCount).toBe(5);
});
});
describe('Full workflow with Last-Modified-only server', () => {
it('should complete full sync workflow with Last-Modified fallback', async () => {
// Configure server with Last-Modified support only
const configWithLastModified: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: false,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: true,
},
};
const apiWithLastModified = new WebdavApi(async () => configWithLastModified);
const timestamp1 = 'Wed, 21 Oct 2015 07:28:00 GMT';
const timestamp2 = 'Thu, 22 Oct 2015 08:30:00 GMT';
// Mock responses
const lockResponse = createMockResponse(
200,
{
'lock-token': '<opaquelocktoken:abc123>',
},
`<?xml version="1.0"?>
<d:prop xmlns:d="DAV:">
<d:lockdiscovery>
<d:activelock>
<d:locktoken>
<d:href>opaquelocktoken:abc123</d:href>
</d:locktoken>
</d:activelock>
</d:lockdiscovery>
</d:prop>`,
);
const uploadResponse = createMockResponse(201, {
'last-modified': timestamp1,
});
const downloadResponse = createMockResponse(
200,
{
'last-modified': timestamp1,
},
'file content v1',
);
const updateResponse = createMockResponse(200, {
'last-modified': timestamp2,
});
const deleteResponse = createMockResponse(204);
let callCount = 0;
mockFetch.and.callFake((url, options) => {
callCount++;
const method = options?.method || 'GET';
if (method === 'LOCK' && callCount === 1) {
// Lock for safe creation
return Promise.resolve(lockResponse);
} else if (method === 'PUT' && callCount === 2) {
// Initial upload with lock token
return Promise.resolve(uploadResponse);
} else if (method === 'UNLOCK' && callCount === 3) {
// Unlock after creation
return Promise.resolve(createMockResponse(204));
} else if (method === 'GET' && callCount === 4) {
// Download
return Promise.resolve(downloadResponse);
} else if (method === 'PUT' && callCount === 5) {
// Update with Last-Modified conditional headers
return Promise.resolve(updateResponse);
} else if (method === 'PROPFIND' && callCount === 6) {
// getFileMeta call before delete to check resource type
return Promise.resolve(
createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/test.txt</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>${timestamp2}</d:getlastmodified>
<d:resourcetype/>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
),
);
} else if (method === 'DELETE' && callCount === 7) {
// Delete with Last-Modified conditional headers
return Promise.resolve(deleteResponse);
}
throw new Error(`Unexpected call: ${method} at call ${callCount}`);
});
// 1. Create new file (with LOCK/UNLOCK)
const createResult = await apiWithLastModified.upload({
path: '/test.txt',
data: 'file content v1',
isOverwrite: false,
});
expect(createResult).toBe(timestamp1);
// 2. Download file
const downloadResult = await apiWithLastModified.download({ path: '/test.txt' });
expect(downloadResult.rev).toBe(timestamp1);
expect(downloadResult.dataStr).toBe('file content v1');
// 3. Update file
const updateResult = await apiWithLastModified.upload({
path: '/test.txt',
data: 'file content v2',
expectedEtag: timestamp1,
});
expect(updateResult).toBe(timestamp2);
// 4. Delete file
await apiWithLastModified.remove('/test.txt', timestamp2);
expect(callCount).toBe(7);
});
});
describe('Mixed server capabilities workflow', () => {
it('should handle server that supports both ETag and Last-Modified', async () => {
// Configure server with both ETag and Last-Modified support
const configWithBoth: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: true,
supportsLastModified: true,
supportsIfHeader: true,
supportsLocking: true,
},
};
const apiWithBoth = new WebdavApi(async () => configWithBoth);
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
// Mock responses with both validators
const uploadResponse = createMockResponse(201, {
etag: '"v1"',
'last-modified': timestamp,
});
const downloadResponse = createMockResponse(
200,
{
etag: '"v1"',
'last-modified': timestamp,
},
'file content',
);
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
// Should prefer ETag over Last-Modified for safe creation
return Promise.resolve(uploadResponse);
} else if (method === 'GET') {
return Promise.resolve(downloadResponse);
}
throw new Error(`Unexpected call: ${method}`);
});
// Should use ETag even though Last-Modified is available
const result = await apiWithBoth.upload({
path: '/test.txt',
data: 'file content',
isOverwrite: false,
});
expect(result).toBe('v1');
const download = await apiWithBoth.download({ path: '/test.txt' });
expect(download.rev).toBe('v1'); // Should return ETag, not Last-Modified
});
});
describe('Capability detection and adaptation', () => {
it('should detect capabilities and adapt strategy mid-workflow', async () => {
// Start without pre-configured capabilities
const apiWithoutCapabilities = new WebdavApi(async () => mockConfig);
// Mock capability detection
const capabilityDetectionResponse = createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/sync/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>Wed, 21 Oct 2015 07:28:00 GMT</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
);
const timestamp = 'Thu, 22 Oct 2015 08:30:00 GMT';
// First upload fails with no validator
const firstUploadResponse = createMockResponse(201, {});
// Retry succeeds with Last-Modified
const retryUploadResponse = createMockResponse(201, {
'last-modified': timestamp,
});
let uploadAttempts = 0;
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
uploadAttempts++;
if (uploadAttempts === 1) {
return Promise.resolve(firstUploadResponse);
} else {
return Promise.resolve(retryUploadResponse);
}
} else if (method === 'PROPFIND') {
if (url.includes('/test.txt')) {
return Promise.reject(new RemoteFileNotFoundAPIError('/test.txt'));
} else {
// Capability detection
return Promise.resolve(capabilityDetectionResponse);
}
} else if (method === 'GET') {
return Promise.reject(new RemoteFileNotFoundAPIError('/test.txt'));
}
throw new Error(`Unexpected call: ${method}`);
});
const result = await apiWithoutCapabilities.upload({
path: '/test.txt',
data: 'content',
});
expect(result).toBe(timestamp);
expect(uploadAttempts).toBe(2);
});
});
describe('Error recovery scenarios', () => {
it('should handle partial failures gracefully', async () => {
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
// LOCK fails, but upload still works
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'LOCK') {
return Promise.reject(new Error('Lock not supported'));
} else if (method === 'PUT') {
return Promise.resolve(
createMockResponse(201, {
'last-modified': timestamp,
}),
);
}
throw new Error(`Unexpected call: ${method}`);
});
const configWithLocking: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: false,
supportsLastModified: true,
supportsIfHeader: false,
supportsLocking: true,
},
};
const apiWithLocking = new WebdavApi(async () => configWithLocking);
// Should succeed despite lock failure
const result = await apiWithLocking.upload({
path: '/test.txt',
data: 'content',
isOverwrite: false,
});
expect(result).toBe(timestamp);
});
it('should provide clear error when no fallback works', async () => {
// Server returns no validators
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
return Promise.resolve(createMockResponse(201, {}));
} else if (method === 'PROPFIND' || method === 'GET') {
return Promise.reject(new RemoteFileNotFoundAPIError('/test.txt'));
}
throw new Error(`Unexpected call: ${method}`);
});
const configNoValidators: WebdavPrivateCfg = {
...mockConfig,
serverCapabilities: {
supportsETags: true,
supportsLastModified: false,
supportsIfHeader: false,
supportsLocking: false,
},
};
const apiNoValidators = new WebdavApi(async () => configNoValidators);
await expectAsync(
apiNoValidators.upload({ path: '/test.txt', data: 'content' }),
).toBeRejectedWith(jasmine.any(NoEtagAPIError));
});
});
describe('Performance considerations', () => {
it('should cache detected capabilities to avoid repeated detection', async () => {
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
let propfindCount = 0;
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PROPFIND') {
propfindCount++;
if (url.includes('/sync/')) {
// Capability detection
return Promise.resolve(
createMockResponse(
207,
{
'content-type': 'application/xml',
},
`<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:">
<d:response>
<d:href>/sync/</d:href>
<d:propstat>
<d:prop>
<d:getlastmodified>${timestamp}</d:getlastmodified>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`,
),
);
}
} else if (method === 'PUT') {
return Promise.resolve(
createMockResponse(201, {
'last-modified': timestamp,
}),
);
}
throw new Error(`Unexpected call: ${method}`);
});
// First call triggers detection
const capabilities1 = await api.detectServerCapabilities();
expect(capabilities1.supportsLastModified).toBe(true);
expect(propfindCount).toBe(1);
// Second call uses cache
const capabilities2 = await api.detectServerCapabilities();
expect(capabilities2).toEqual(capabilities1);
expect(propfindCount).toBe(1); // No additional PROPFIND
// Upload operations use cached capabilities
await api.upload({ path: '/test1.txt', data: 'content1' });
await api.upload({ path: '/test2.txt', data: 'content2' });
expect(propfindCount).toBe(1); // Still no additional PROPFIND
});
});
describe('Backward compatibility', () => {
it('should maintain backward compatibility with existing ETag-based code', async () => {
// Test that existing code expecting ETags continues to work
const uploadResponse = createMockResponse(201, {
etag: '"abc123"',
});
const downloadResponse = createMockResponse(
200,
{
etag: '"abc123"',
},
'content',
);
mockFetch.and.callFake((url, options) => {
const method = options?.method || 'GET';
if (method === 'PUT') {
return Promise.resolve(uploadResponse);
} else if (method === 'GET') {
return Promise.resolve(downloadResponse);
}
throw new Error(`Unexpected call: ${method}`);
});
// Should return cleaned ETag as before
const uploadResult = await api.upload({ path: '/test.txt', data: 'content' });
expect(uploadResult).toBe('abc123');
const downloadResult = await api.download({ path: '/test.txt' });
expect(downloadResult.rev).toBe('abc123');
expect(downloadResult.dataStr).toBe('content');
});
});
});

View file

@ -124,7 +124,12 @@ describe('WebdavApi Last-Modified Support', () => {
describe('_createConditionalHeaders method', () => {
it('should create ETag-based headers when ETag provided', async () => {
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(false, '"abc123"', null, 'etag');
const headers = await api._createConditionalHeaders(
false,
'"abc123"',
null,
'etag',
);
expect(headers['If-Match']).toBe('"abc123"');
expect(headers['If-Unmodified-Since']).toBeUndefined();
@ -136,7 +141,7 @@ describe('WebdavApi Last-Modified Support', () => {
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(
const headers = await api._createConditionalHeaders(
false,
null,
timestamp,
@ -151,7 +156,7 @@ describe('WebdavApi Last-Modified Support', () => {
it('should create If-None-Match for new file creation with ETag', async () => {
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(false, null, null, 'etag');
const headers = await api._createConditionalHeaders(false, null, null, 'etag');
expect(headers['If-None-Match']).toBe('*');
expect(headers['If-Match']).toBeUndefined();
@ -162,14 +167,19 @@ describe('WebdavApi Last-Modified Support', () => {
it('should not create conditional headers for new file creation with Last-Modified only', async () => {
// Last-Modified cannot safely handle resource creation
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(false, null, null, 'last-modified');
const headers = await api._createConditionalHeaders(
false,
null,
null,
'last-modified',
);
expect(Object.keys(headers).length).toBe(0);
});
it('should handle overwrite mode with ETag', async () => {
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(true, '"abc123"', null, 'etag');
const headers = await api._createConditionalHeaders(true, '"abc123"', null, 'etag');
expect(headers['If-Match']).toBe('"abc123"');
expect(headers['If-None-Match']).toBeUndefined();
@ -179,7 +189,7 @@ describe('WebdavApi Last-Modified Support', () => {
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(
const headers = await api._createConditionalHeaders(
true,
null,
timestamp,
@ -194,7 +204,12 @@ describe('WebdavApi Last-Modified Support', () => {
const timestamp = 'Wed, 21 Oct 2015 07:28:00 GMT';
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(false, '"abc123"', timestamp, 'etag');
const headers = await api._createConditionalHeaders(
false,
'"abc123"',
timestamp,
'etag',
);
expect(headers['If-Match']).toBe('"abc123"');
expect(headers['If-Unmodified-Since']).toBeUndefined();
@ -202,7 +217,12 @@ describe('WebdavApi Last-Modified Support', () => {
it('should handle missing validator type gracefully', async () => {
// @ts-expect-error - accessing private method for testing
const headers = api._createConditionalHeaders(false, '"abc123"', null, 'none');
const headers = await api._createConditionalHeaders(
false,
'"abc123"',
null,
'none',
);
// Should fall back to ETag behavior when validator is provided
expect(headers['If-Match']).toBe('"abc123"');

View file

@ -17,6 +17,13 @@ describe('WebdavApi - Upload Operations', () => {
userName: 'testuser',
password: 'testpass',
syncFolderPath: '/sync',
// Pre-configure server capabilities to prevent detection calls
serverCapabilities: {
supportsETags: true,
supportsIfHeader: true,
supportsLocking: false,
supportsLastModified: false,
},
};
const createMockResponse = (

View file

@ -169,14 +169,19 @@ export class WebdavApi {
: validators.validator;
} catch (retryError: any) {
PFLog.err(`${WebdavApi.L}.upload() retry after ${errorCode} failed`, retryError);
if (retryError instanceof RemoteFileNotFoundAPIError) {
throw retryError;
}
if (retryError instanceof NoEtagAPIError) {
// Preserve all API error types to maintain test compatibility
if (
retryError instanceof NoEtagAPIError ||
retryError instanceof NoRevAPIError ||
retryError instanceof RemoteFileNotFoundAPIError
) {
throw retryError;
}
// For other errors, wrap with context but preserve original message
throw new Error(
`Upload failed after creating directories: ${retryError?.message || 'Unknown error'}`,
`Upload failed after creating directories: ${path} - ${retryError?.message || 'Unknown error'}`,
);
}
}
@ -274,10 +279,16 @@ export class WebdavApi {
throw new NoRevAPIError(path);
}
throw new NoEtagAPIError({
path,
// Include attempted methods and context for better debugging
const additionalLog = {
attemptedMethods: ['response-headers', 'PROPFIND', 'GET'],
});
context,
capabilities,
};
const error = new NoEtagAPIError(path);
(error as any).additionalLog = additionalLog;
throw error;
}
}
}
@ -408,7 +419,7 @@ export class WebdavApi {
return null;
} catch (e: any) {
PFLog.err(`${WebdavApi.L}._lockResource() failed`, { path, error: e });
PFLog.error(`${WebdavApi.L}._lockResource() failed`, { path, error: e });
return null;
}
}
@ -436,19 +447,45 @@ export class WebdavApi {
path,
isOverwrite,
expectedEtag,
_retryAttempted,
}: {
data: string;
path: string;
isOverwrite?: boolean;
expectedEtag?: string | null;
_retryAttempted?: boolean;
}): Promise<string | undefined> {
// Get configuration to check for compatibility options
const cfg = await this._getCfgOrError();
// Check for basic compatibility mode
if (cfg.basicCompatibilityMode) {
// In basic compatibility mode, just upload without conditional headers
const response = await this._makeRequest({
method: 'PUT',
path,
body: data,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': new Blob([data]).size.toString(),
},
});
const responseHeaderObj = this._responseHeadersToObject(response);
const validators = this._extractValidators(responseHeaderObj);
return validators.validator || 'basic-mode-upload';
}
// Get capabilities to determine validator type
const capabilities = await this._getOrDetectCapabilities();
const validatorType = capabilities.supportsETags
? 'etag'
: capabilities.supportsLastModified
const validatorType =
cfg.preferLastModified && capabilities.supportsLastModified
? 'last-modified'
: 'none';
: capabilities.supportsETags
? 'etag'
: capabilities.supportsLastModified
? 'last-modified'
: 'none';
// Check if we need locking for safe creation
const needsLockingForCreation =
@ -466,10 +503,7 @@ export class WebdavApi {
// Try to lock the resource for safe creation
lockToken = await this._lockResource(path);
if (!lockToken) {
PFLog.error(
`${WebdavApi.L}.upload() Failed to acquire lock for safe creation`,
{ path },
);
console.error(`Failed to acquire lock for safe creation`, { path });
}
}
@ -498,6 +532,23 @@ export class WebdavApi {
headers,
});
// Check response status before processing
if (response.status === 412) {
// Precondition Failed - conditional header failed
throw new NoEtagAPIError(path);
} else if (response.status === 423) {
// Locked
throw new HttpNotOkAPIError(response);
} else if (response.status === 409) {
// Conflict - parent directory doesn't exist
return await this._retryUploadWithDirectoryCreation(
path,
data,
headers || {},
409,
);
}
// Extract validator from response headers
const responseHeaderObj = this._responseHeadersToObject(response);
const validators = this._extractValidators(responseHeaderObj);
@ -517,7 +568,10 @@ export class WebdavApi {
PFLog.err(`${WebdavApi.L}.upload() error`, { path, error: e });
// Check if it's a RemoteFileNotFoundAPIError (404)
if (e instanceof RemoteFileNotFoundAPIError || e?.status === 404) {
if (
(e instanceof RemoteFileNotFoundAPIError || e?.status === 404) &&
!_retryAttempted
) {
// Not found - parent directory might not exist (some WebDAV servers return 404 instead of 409)
return await this._retryUploadWithDirectoryCreation(
path,
@ -527,8 +581,11 @@ export class WebdavApi {
);
}
// Check for HttpNotOkAPIError which has response.status
const errorStatus = e instanceof HttpNotOkAPIError ? e.response.status : e?.status;
// Enhanced error handling for WebDAV-specific scenarios
switch (e?.status) {
switch (errorStatus) {
case 409:
// Conflict - parent directory doesn't exist
return await this._retryUploadWithDirectoryCreation(
@ -541,35 +598,74 @@ export class WebdavApi {
// Precondition Failed - conditional header failed
if (expectedEtag) {
throw new Error(
`Upload failed: file was modified (expected etag: ${expectedEtag})`,
`Upload failed: file was modified (expected validator: ${expectedEtag})`,
);
} else {
throw new FileExistsAPIError();
}
case 413:
// Payload Too Large
throw new Error(`File too large: ${path}`);
throw new HttpNotOkAPIError(e.response || ({ status: 413 } as Response));
case 507:
// Insufficient Storage
throw new Error(`Insufficient storage space for: ${path}`);
throw new HttpNotOkAPIError(e.response || ({ status: 507 } as Response));
case 423:
// Locked
throw new Error(`Resource is locked: ${path}`);
throw new HttpNotOkAPIError(e.response || ({ status: 423 } as Response));
default:
// Handle NoRevAPIError - server doesn't support ETags
// Handle NoRevAPIError - server doesn't support ETags or had retrieval issues
if (e instanceof NoRevAPIError) {
PFLog.info(
`${WebdavApi.L}.upload() NoRevAPIError detected, checking server capabilities`,
);
// Get current capabilities to check if this was from initial detection
const retryCapabilities = await this._getOrDetectCapabilities();
// Clear cached capabilities to force re-detection
this._cachedCapabilities = undefined;
// Only retry if:
// 1. Capabilities show server doesn't support ETags (so we need Last-Modified)
// 2. This is not a retry attempt
// 3. Server capabilities were detected (not pre-configured)
const retryCfg = await this._getCfgOrError();
const wasDetected = !retryCfg.serverCapabilities; // If not in config, they were detected
// Detect server capabilities
await this.detectServerCapabilities();
// Check retry limits
const maxRetries = retryCfg.maxRetries ?? 2;
const retryCount = (e as any)._retryCount || 0;
// Retry upload with updated capabilities
return await this.upload({ data, path, isOverwrite, expectedEtag });
// Only retry if server supports Last-Modified and we haven't exceeded retry limit
if (
retryCount < maxRetries &&
wasDetected &&
retryCapabilities.supportsLastModified
) {
PFLog.info(
`${WebdavApi.L}.upload() NoRevAPIError detected, retrying with fresh capabilities`,
);
// Clear cached capabilities to force re-detection
this._cachedCapabilities = undefined;
// Detect server capabilities fresh
await this.detectServerCapabilities();
// Track retry count to prevent infinite loops
const retryData = { _retryCount: retryCount + 1, _isRetryAttempt: true };
// Retry upload with updated capabilities
try {
return await this.upload({
data,
path,
isOverwrite,
expectedEtag,
_retryAttempted: true,
});
} catch (retryErr: any) {
// If retry also fails with NoRevAPIError, propagate retry info and throw
if (retryErr instanceof NoRevAPIError) {
Object.assign(retryErr, retryData);
throw retryErr;
}
throw retryErr;
}
}
}
throw e;
}
@ -816,11 +912,17 @@ export class WebdavApi {
} catch (e: any) {
PFLog.err(`${WebdavApi.L}.download() error`, { path, error: e });
// Check for HttpNotOkAPIError which has response.status
const errorStatus = e instanceof HttpNotOkAPIError ? e.response.status : e?.status;
// Enhanced error handling
switch (e?.status) {
switch (errorStatus) {
case 304:
// Not Modified - rethrow as is since this might be expected
throw e;
// Not Modified - create custom error with needed info
const notModifiedError = new Error(`File not modified since: ${localRev}`);
(notModifiedError as any).status = 304;
(notModifiedError as any).localRev = localRev;
throw notModifiedError;
case 404:
// Not Found - file doesn't exist
throw new RemoteFileNotFoundAPIError(path);
@ -898,6 +1000,15 @@ export class WebdavApi {
}
}
// Check for other error statuses that might not throw from _makeRequest
if (response.status === 409) {
throw new Error(`Cannot delete non-empty directory: ${path}`);
} else if (response.status === 423) {
throw new Error(`Cannot delete locked resource: ${path}`);
} else if (response.status === 424) {
throw new Error(`Delete operation failed due to dependency: ${path}`);
}
PFLog.normal(`${WebdavApi.L}.remove() successfully deleted: ${path}`);
} catch (e: any) {
PFLog.err(`${WebdavApi.L}.remove() error`, { path, error: e });
@ -911,7 +1022,10 @@ export class WebdavApi {
return;
}
switch (e?.status) {
// Check for HttpNotOkAPIError which has response.status
const errorStatus = e instanceof HttpNotOkAPIError ? e.response.status : e?.status;
switch (errorStatus) {
case 412:
// Precondition Failed - etag mismatch
if (expectedEtag) {
@ -925,10 +1039,10 @@ export class WebdavApi {
throw new Error(`Cannot delete locked resource: ${path}`);
case 424:
// Failed Dependency
throw new Error(`Delete failed due to dependency: ${path}`);
throw new Error(`Delete operation failed due to dependency: ${path}`);
case 409:
// Conflict - might be non-empty directory
throw new Error(`Delete conflict (non-empty directory?): ${path}`);
throw new Error(`Cannot delete non-empty directory: ${path}`);
default:
throw e;
}
@ -1274,6 +1388,7 @@ export class WebdavApi {
412, // Precondition Failed (let calling methods handle this)
416, // Range Not Satisfiable (let calling methods handle this)
423, // Locked (let calling methods handle this)
424, // Failed Dependency (let calling methods handle this)
];
if (!validWebDavStatuses.includes(response.status)) {

View file

@ -10,9 +10,13 @@ import {
import { SyncProviderPrivateCfgBase } from '../../../pfapi.model';
export interface WebdavServerCapabilities {
/** Whether the server supports ETag headers for versioning */
supportsETags: boolean;
/** Whether the server supports WebDAV If headers (RFC 4918) */
supportsIfHeader: boolean;
/** Whether the server supports WebDAV LOCK/UNLOCK operations */
supportsLocking: boolean;
/** Whether the server supports Last-Modified headers for versioning */
supportsLastModified: boolean;
}
@ -21,8 +25,102 @@ export interface WebdavPrivateCfg extends SyncProviderPrivateCfgBase {
userName: string;
password: string;
syncFolderPath: string;
/**
* Server capabilities configuration. If not provided, capabilities will be
* detected automatically on first use. Providing this configuration can
* improve performance by skipping detection and ensure consistent behavior.
*
* Recommended settings for common servers:
* - Nextcloud/ownCloud: { supportsETags: true, supportsIfHeader: true, supportsLocking: true, supportsLastModified: true }
* - Apache mod_dav: { supportsETags: true, supportsIfHeader: false, supportsLocking: true, supportsLastModified: true }
* - Basic WebDAV: { supportsETags: false, supportsIfHeader: false, supportsLocking: false, supportsLastModified: true }
*/
serverCapabilities?: WebdavServerCapabilities;
preferLastModified?: boolean; // Force Last-Modified for testing
/**
* Force the use of Last-Modified headers instead of ETags, even if ETags are available.
* This can be useful for testing fallback behavior or working with servers that have
* unreliable ETag implementations.
*/
preferLastModified?: boolean;
/**
* Compatibility mode for servers with limited WebDAV support.
* When enabled, disables conditional operations and safe creation mechanisms.
* Use only for very basic WebDAV servers that don't support any conditional headers.
*/
basicCompatibilityMode?: boolean;
/**
* Maximum number of retry attempts for capability detection and fallback operations.
* Default: 2
*/
maxRetries?: number;
}
/**
* Server type enum for common WebDAV implementations
*/
export enum WebdavServerType {
NEXTCLOUD = 'nextcloud',
OWNCLOUD = 'owncloud',
APACHE_MOD_DAV = 'apache_mod_dav',
NGINX_DAV = 'nginx_dav',
BASIC_WEBDAV = 'basic_webdav',
CUSTOM = 'custom',
}
/**
* Helper function to get recommended server capabilities for common WebDAV server types
*/
export const getRecommendedServerCapabilities = (
serverType: WebdavServerType,
): WebdavServerCapabilities => {
switch (serverType) {
case WebdavServerType.NEXTCLOUD:
case WebdavServerType.OWNCLOUD:
return {
supportsETags: true,
supportsIfHeader: true,
supportsLocking: true,
supportsLastModified: true,
};
case WebdavServerType.APACHE_MOD_DAV:
return {
supportsETags: true,
supportsIfHeader: false, // mod_dav has limited If header support
supportsLocking: true,
supportsLastModified: true,
};
case WebdavServerType.NGINX_DAV:
return {
supportsETags: false, // nginx dav module has limited ETag support
supportsIfHeader: false,
supportsLocking: false,
supportsLastModified: true,
};
case WebdavServerType.BASIC_WEBDAV:
return {
supportsETags: false,
supportsIfHeader: false,
supportsLocking: false,
supportsLastModified: true,
};
case WebdavServerType.CUSTOM:
default:
// Return null to trigger auto-detection
return {
supportsETags: false,
supportsIfHeader: false,
supportsLocking: false,
supportsLastModified: false,
};
}
}
export class Webdav implements SyncProviderServiceInterface<SyncProviderId.WebDAV> {