Newer
Older
Sunfish / tests / Sunfish.Tests / DavTests.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 12 KB 3.0
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);
    }
}