using System.Net;
using System.Text;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Dav;
using Sunfish.Host;
using Sunfish.Net;
namespace Sunfish.Tests;
/// <summary>WebDAV contra un servidor real: en bruto (como el Explorador o Finder) y con DavClient.</summary>
public class DavServerTests : IAsyncLifetime
{
private readonly TempDir _rw = new();
private readonly TempDir _ro = new();
private readonly TempDir _off = new();
private TestServer _server = null!;
public Task InitializeAsync()
{
_rw.File("existe.txt", "original");
_rw.File("año/dentro.txt", "x");
_ro.File("solo.txt", "lectura");
_off.File("nada.txt", "n");
var config = new SunfishConfig();
config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
config.Shares.Add(new ShareConfig { Name = "rw", Location = "/rw", Path = _rw.Path, Upload = true, Rename = true, Delete = true });
config.Shares.Add(new ShareConfig { Name = "ro", Location = "/ro", Path = _ro.Path, Users = ["ana"] });
config.Shares.Add(new ShareConfig { Name = "off", Location = "/off", Path = _off.Path, WebDav = false });
_server = new TestServer(config);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
_rw.Dispose();
_ro.Dispose();
_off.Dispose();
}
private Task<RawResponse> Dav(string method, string path, string headers = "", string body = "") =>
_server.SendAsync($"{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n{headers}" +
$"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n\r\n{body}");
private DavClient Client(string? user = null, string? password = null) =>
new(new Uri($"http://127.0.0.1:{_server.Port}/"), user, password);
[Fact]
public async Task OptionsAnnouncesDav()
{
var r = await Dav("OPTIONS", "/");
Assert.Equal(200, r.Status);
Assert.Equal("1, 2", r.Headers["DAV"]);
Assert.Equal("DAV", r.Headers["MS-Author-Via"]);
Assert.Contains("PROPFIND", r.Headers["Allow"]);
}
[Fact]
public async Task PropFindListsTheVirtualRootAndShares()
{
var r = await Dav("PROPFIND", "/", "Depth: 1\r\n");
Assert.Equal(207, r.Status);
string xml = Encoding.UTF8.GetString(r.Body);
Assert.Contains("<D:href>/rw/</D:href>", xml);
Assert.Contains("<D:href>/off/</D:href>", xml);
r = await Dav("PROPFIND", "/rw/", "Depth: 1\r\n");
xml = Encoding.UTF8.GetString(r.Body);
Assert.Contains("<D:href>/rw/a%C3%B1o/</D:href>", xml);
Assert.Contains("<D:getcontentlength>8</D:getcontentlength>", xml);
Assert.Contains("<D:collection />", xml);
Assert.Equal(403, (await Dav("PROPFIND", "/rw/", "Depth: infinity\r\n")).Status);
Assert.Equal(405, (await Dav("PROPFIND", "/off/", "Depth: 1\r\n")).Status);
}
[Fact]
public async Task PropFindWithSpecificProps()
{
string body = "<?xml version=\"1.0\"?><D:propfind xmlns:D=\"DAV:\" xmlns:Z=\"urn:x\"><D:prop><D:getcontentlength/><Z:raro/></D:prop></D:propfind>";
var r = await Dav("PROPFIND", "/rw/existe.txt", "Depth: 0\r\nContent-Type: application/xml\r\n", body);
string xml = Encoding.UTF8.GetString(r.Body);
Assert.Contains("<D:getcontentlength>8</D:getcontentlength>", xml);
Assert.Contains("404 Not Found", xml);
Assert.DoesNotContain("displayname", xml);
}
[Fact]
public async Task ExternalEntitiesAreRejected()
{
string body = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///c:/windows/win.ini\">]><D:propfind xmlns:D=\"DAV:\"><D:prop>&e;</D:prop></D:propfind>";
var r = await Dav("PROPFIND", "/rw/existe.txt", "Depth: 0\r\n", body);
Assert.DoesNotContain("fonts", Encoding.UTF8.GetString(r.Body), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task WindowsStyleSaveSequence()
{
// El Explorador: PUT vacio, LOCK, PUT con datos, PROPPATCH, UNLOCK.
Assert.Equal(201, (await Dav("PUT", "/rw/nuevo.txt")).Status);
var lockResponse = await Dav("LOCK", "/rw/nuevo.txt", "Timeout: Second-3600\r\n",
"<?xml version=\"1.0\"?><D:lockinfo xmlns:D=\"DAV:\"><D:lockscope><D:exclusive/></D:lockscope><D:locktype><D:write/></D:locktype></D:lockinfo>");
Assert.Equal(200, lockResponse.Status);
Assert.StartsWith("<opaquelocktoken:", lockResponse.Headers["Lock-Token"]);
Assert.Equal(204, (await Dav("PUT", "/rw/nuevo.txt", body: "contenido")).Status); // sustituye el vacio sin Overwrite
Assert.Equal(207, (await Dav("PROPPATCH", "/rw/nuevo.txt", body:
"<?xml version=\"1.0\"?><D:propertyupdate xmlns:D=\"DAV:\" xmlns:Z=\"urn:schemas-microsoft-com:\"><D:set><D:prop><Z:Win32FileAttributes>00000020</Z:Win32FileAttributes></D:prop></D:set></D:propertyupdate>")).Status);
Assert.Equal(204, (await Dav("UNLOCK", "/rw/nuevo.txt", $"Lock-Token: {lockResponse.Headers["Lock-Token"]}\r\n")).Status);
Assert.Equal("contenido", File.ReadAllText(Path.Combine(_rw.Path, "nuevo.txt")));
Assert.Equal(409, (await Dav("PUT", "/rw/existe.txt", body: "pisado")).Status); // no vacio y sin Overwrite
}
[Fact]
public async Task FinderMetadataIsAccepted()
{
Assert.Equal(201, (await Dav("PUT", "/rw/._existe.txt", body: "appledouble")).Status);
Assert.True(File.Exists(Path.Combine(_rw.Path, "._existe.txt")));
var list = Encoding.UTF8.GetString((await Dav("PROPFIND", "/rw/", "Depth: 1\r\n")).Body);
Assert.DoesNotContain("._existe", list); // oculto en los listados
Assert.Equal(400, (await Dav("PUT", "/rw/.oculto", body: "x")).Status);
}
[Fact]
public async Task MkColCopyMoveDelete()
{
Assert.Equal(201, (await Dav("MKCOL", "/rw/carpeta")).Status);
Assert.Equal(405, (await Dav("MKCOL", "/rw/carpeta")).Status);
Assert.Equal(409, (await Dav("MKCOL", "/rw/no/existe")).Status);
Assert.Equal(201, (await Dav("COPY", "/rw/existe.txt", $"Destination: http://localhost:{_server.Port}/rw/carpeta/copia.txt\r\n")).Status);
Assert.Equal(201, (await Dav("MOVE", "/rw/carpeta/copia.txt", "Destination: /rw/movida.txt\r\n")).Status);
Assert.Equal(412, (await Dav("MOVE", "/rw/movida.txt", "Destination: /rw/existe.txt\r\nOverwrite: F\r\n")).Status);
Assert.Equal(204, (await Dav("MOVE", "/rw/movida.txt", "Destination: /rw/existe.txt\r\nOverwrite: T\r\n")).Status);
Assert.Equal(502, (await Dav("COPY", "/rw/existe.txt", "Destination: /ro/x.txt\r\n")).Status);
Assert.Equal(400, (await Dav("COPY", "/rw/existe.txt", "Destination: /rw/../../x.txt\r\n")).Status);
Assert.Equal(201, (await Dav("COPY", "/rw/a%C3%B1o/", "Destination: /rw/copia-dir/\r\n")).Status);
Assert.True(File.Exists(Path.Combine(_rw.Path, "copia-dir", "dentro.txt")));
Assert.Equal(204, (await Dav("DELETE", "/rw/copia-dir/")).Status);
Assert.False(Directory.Exists(Path.Combine(_rw.Path, "copia-dir")));
Assert.Equal(403, (await Dav("DELETE", "/rw/")).Status); // la raiz del share no se borra
}
[Fact]
public async Task ProtectedSharesNeedCredentials()
{
Assert.Equal(401, (await Dav("PROPFIND", "/ro/", "Depth: 1\r\n")).Status);
using var client = Client("ana", "1234");
var items = await client.ListAsync("/ro");
Assert.Equal(["solo.txt"], items.Select(i => i.Name));
var e = await Assert.ThrowsAsync<DavException>(() => client.CreateFolderAsync("/ro/nueva"));
Assert.Equal(403, e.Status);
}
[Fact]
public async Task ClientRoundTrip()
{
using var client = Client();
var root = await client.ListAsync("/");
Assert.Contains(root, i => i.Name == "rw" && i.IsCollection);
var items = await client.ListAsync("/rw");
Assert.Contains(items, i => i.Name == "año" && i.IsCollection);
Assert.Contains(items, i => i.Name == "existe.txt" && i.Size == 8 && i.Modified is not null);
await client.UploadAsync("/rw/subido.txt", new MemoryStream(Encoding.UTF8.GetBytes("hola")));
var ms = new MemoryStream();
await client.DownloadAsync("/rw/subido.txt", ms);
Assert.Equal("hola", Encoding.UTF8.GetString(ms.ToArray()));
using var local = new TempDir();
local.File("árbol/hoja.txt", "h");
await client.UploadTreeAsync(Path.Combine(local.Path, "árbol"), "/rw");
Assert.True(File.Exists(Path.Combine(_rw.Path, "árbol", "hoja.txt")));
using var back = new TempDir();
await client.DownloadTreeAsync(new DavItem("árbol", "/rw/árbol", true, 0, null), back.Path);
Assert.Equal("h", File.ReadAllText(Path.Combine(back.Path, "árbol", "hoja.txt")));
await client.MoveAsync("/rw/subido.txt", "/rw/renombrado.txt", false);
await client.DeleteAsync("/rw/renombrado.txt", false);
Assert.False(File.Exists(Path.Combine(_rw.Path, "renombrado.txt")));
}
[Theory]
[InlineData("192.168.1.10:8080", "http://192.168.1.10:8080/", "/")]
[InlineData("http://equipo:8080/fotos/2026/", "http://equipo:8080/", "/fotos/2026/")]
[InlineData("equipo/a%20b", "http://equipo/", "/a b")]
public void ParsesAddresses(string text, string server, string path)
{
Assert.True(DavClient.TryParseAddress(text, out var uri, out var p));
Assert.Equal(server, uri.ToString());
Assert.Equal(path, p);
}
}
public class DnsMessageTests
{
[Fact]
public void RoundTripsDnsSdRecords()
{
var m = new DnsMessage { Id = 7, IsResponse = true };
m.Questions.Add(("_webdav._tcp.local", DnsType.Ptr, true));
m.Answers.Add(new DnsRecord("_webdav._tcp.local", DnsType.Ptr, 4500) { Target = "Sunfish (PC.1)._webdav._tcp.local" });
m.Additional.Add(new DnsRecord("Sunfish (PC.1)._webdav._tcp.local", DnsType.Srv, 120) { Target = "sunfish-pc.local", Port = 8080, CacheFlush = true });
m.Additional.Add(new DnsRecord("Sunfish (PC.1)._webdav._tcp.local", DnsType.Txt, 4500) { Text = ["sunfish=3", "path=/"] });
m.Additional.Add(new DnsRecord("sunfish-pc.local", DnsType.A, 120) { Address = IPAddress.Parse("192.168.1.10") });
var d = DnsMessage.Decode(m.Encode())!;
Assert.True(d.IsResponse);
Assert.Equal(("_webdav._tcp.local", DnsType.Ptr, true), d.Questions[0]);
Assert.Equal("Sunfish (PC.1)._webdav._tcp.local", d.Answers[0].Target);
var srv = d.Additional.Single(r => r.Type == DnsType.Srv);
Assert.Equal(8080, srv.Port);
Assert.Equal("sunfish-pc.local", srv.Target);
Assert.True(srv.CacheFlush);
Assert.Equal(["sunfish=3", "path=/"], d.Additional.Single(r => r.Type == DnsType.Txt).Text);
Assert.Equal(IPAddress.Parse("192.168.1.10"), d.Additional.Single(r => r.Type == DnsType.A).Address);
}
[Fact]
public void ReadsCompressedNames()
{
// Pregunta "a.local" y una respuesta PTR cuyo nombre apunta a la pregunta (0xC00C).
byte[] packet =
[
0, 1, 0x84, 0, 0, 1, 0, 1, 0, 0, 0, 0,
1, (byte)'a', 5, (byte)'l', (byte)'o', (byte)'c', (byte)'a', (byte)'l', 0, 0, 12, 0, 1,
0xC0, 12, 0, 12, 0, 1, 0, 0, 0, 60, 0, 4, 1, (byte)'b', 0xC0, 14,
];
var d = DnsMessage.Decode(packet)!;
Assert.Equal("a.local", d.Answers[0].Name);
Assert.Equal("b.local", d.Answers[0].Target);
}
[Fact]
public void GarbageIsIgnored() => Assert.Null(DnsMessage.Decode(new byte[] { 1, 2, 3 }));
}
/// <summary>Anuncio y busqueda reales por mDNS en este equipo (se salta si el puerto 5353 no esta disponible).</summary>
[Collection(EnvironmentCollection.Name)]
public class DiscoveryTests
{
[Fact]
public async Task FindsItselfOnTheLan()
{
if (Discovery.LocalIPv4().Count == 0)
return; // sin red
var log = new Sunfish.Log.ServerLog();
await using var announcer = MdnsAnnouncer.Start(new AnnouncedServices("PruebaDescubrimiento", 18080, true, 0), log);
if (announcer is null)
return; // el sistema no comparte el puerto 5353
var found = await Discovery.BrowseAsync(TimeSpan.FromSeconds(2));
Assert.Contains(found, s => s.Name.StartsWith("PruebaDescubrimiento", StringComparison.Ordinal) && s.Port == 18080 && s.IsSunfish);
}
}