Newer
Older
Sunfish / tests / Sunfish.Tests / WriteUnitTests.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 10 KB 3.0
using System.IO.Compression;
using System.Net;
using System.Text;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Shares;
using Sunfish.Web;

namespace Sunfish.Tests;

public class AuthTests
{
    [Fact]
    public void HashesVerifyAndAreSalted()
    {
        string a = Passwords.Hash("secreto", 1000);
        string b = Passwords.Hash("secreto", 1000);
        Assert.NotEqual(a, b);
        Assert.StartsWith("pbkdf2-sha256$1000$", a);
        Assert.True(Passwords.Verify("secreto", a));
        Assert.False(Passwords.Verify("Secreto", a));
        Assert.False(Passwords.Verify("secreto", "basura"));
    }

    [Fact]
    public void ParsesBasicHeaders()
    {
        string header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("ana:pa:ss ñ"));
        Assert.True(Authenticator.TryParseBasic(header, out var user, out var password));
        Assert.Equal("ana", user);
        Assert.Equal("pa:ss ñ", password);

        header = "Basic " + Convert.ToBase64String(Encoding.Latin1.GetBytes("ana:ñ"));
        Assert.True(Authenticator.TryParseBasic(header, out _, out password));
        Assert.Equal("ñ", password);

        Assert.False(Authenticator.TryParseBasic("Bearer x", out _, out _));
        Assert.False(Authenticator.TryParseBasic("Basic !!!", out _, out _));
    }

    [Fact]
    public void ChecksUsersListsAndBlocksAfterFailures()
    {
        var now = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
        var auth = new Authenticator { Now = () => now };
        var users = new[] { new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) } };
        var ip = IPAddress.Parse("10.0.0.5");

        Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ANA", "1234", ip));
        Assert.Equal(AuthResult.Wrong, auth.Check(users, ["pepe"], "ana", "1234", ip)); // no esta en el share
        Assert.Equal(AuthResult.Missing, auth.Check(users, ["ana"], null, null, ip));

        for (int i = 0; i < Authenticator.MaxFailures; i++)
            auth.Check(users, ["ana"], "ana", "mal", ip);
        Assert.Equal(AuthResult.Blocked, auth.Check(users, ["ana"], "ana", "1234", ip));
        Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ana", "1234", IPAddress.Parse("10.0.0.6")));

        now += Authenticator.BlockTime + TimeSpan.FromSeconds(1);
        Assert.Equal(AuthResult.Ok, auth.Check(users, ["ana"], "ana", "1234", ip));
    }
}

public class CsrfTokensTests
{
    [Fact]
    public void TokensDependOnActionFolderAndTime()
    {
        var now = new DateTime(2026, 1, 1, 12, 30, 0, DateTimeKind.Utc);
        var tokens = new CsrfTokens { Now = () => now };
        string t = tokens.Create("upload", "/fotos");

        Assert.True(tokens.Validate(t, "upload", "/fotos"));
        Assert.False(tokens.Validate(t, "delete", "/fotos"));
        Assert.False(tokens.Validate(t, "upload", "/otra"));
        Assert.False(tokens.Validate(null, "upload", "/fotos"));
        Assert.False(new CsrfTokens { Now = () => now }.Validate(t, "upload", "/fotos")); // otra ejecucion

        now = now.AddHours(1);
        Assert.True(tokens.Validate(t, "upload", "/fotos"));
        now = now.AddHours(1);
        Assert.False(tokens.Validate(t, "upload", "/fotos"));
    }
}

/// <summary>Devuelve los datos de pocos bytes en pocos bytes, para partir los delimitadores.</summary>
internal sealed class TrickleStream(byte[] data, int chunk) : MemoryStream(data)
{
    public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
        base.ReadAsync(buffer[..Math.Min(chunk, buffer.Length)], ct);
}

public class MultipartReaderTests
{
    private const string Body =
        "preambulo ignorado\r\n" +
        "--XyZ\r\n" +
        "Content-Disposition: form-data; name=\"a\"\r\n\r\n" +
        "upload\r\n" +
        "--XyZ\r\n" +
        "Content-Disposition: form-data; name=\"f\"; filename=\"C:\\WINDOWS\\a;b.txt\"\r\n" +
        "Content-Type: text/plain\r\n\r\n" +
        "linea 1\r\nx--XyZ no es\r\nfin" +
        "\r\n--XyZ\n" +
        "Content-Disposition: form-data; name=\"vacio\"; filename=\"\"\n\n" +
        "\r\n--XyZ--\r\n";

    [Theory]
    [InlineData(1)]
    [InlineData(3)]
    [InlineData(7)]
    [InlineData(4096)]
    public async Task ReadsSectionsWithSplitDelimiters(int chunk)
    {
        var reader = new MultipartReader(new TrickleStream(Encoding.Latin1.GetBytes(Body), chunk), "XyZ");

        var a = await reader.NextAsync(CancellationToken.None);
        Assert.Equal("a", a!.Name);
        Assert.Null(a.FileName);
        Assert.Equal("upload", await MultipartReader.ReadFieldAsync(a, 100, CancellationToken.None));

        var f = await reader.NextAsync(CancellationToken.None);
        Assert.Equal("C:\\WINDOWS\\a;b.txt", f!.FileName);
        Assert.Equal("a;b.txt", FileOperations.CleanUploadName(f.FileName!));
        var ms = new MemoryStream();
        await f.Body.CopyToAsync(ms);
        Assert.Equal("linea 1\r\nx--XyZ no es\r\nfin", Encoding.Latin1.GetString(ms.ToArray()));

        var empty = await reader.NextAsync(CancellationToken.None);
        Assert.Equal("", empty!.FileName);

        Assert.Null(await reader.NextAsync(CancellationToken.None));
    }

    [Fact]
    public async Task UnreadSectionsAreSkipped()
    {
        var reader = new MultipartReader(new MemoryStream(Encoding.Latin1.GetBytes(Body)), "XyZ");
        await reader.NextAsync(CancellationToken.None);
        var f = await reader.NextAsync(CancellationToken.None);
        Assert.Equal("f", f!.Name);
    }

    [Fact]
    public async Task TruncatedBodiesFail()
    {
        var reader = new MultipartReader(new MemoryStream(Encoding.Latin1.GetBytes("--XyZ\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\nsin fin")), "XyZ");
        var a = await reader.NextAsync(CancellationToken.None);
        await Assert.ThrowsAsync<InvalidDataException>(() => MultipartReader.ReadFieldAsync(a!, 100, CancellationToken.None));
    }

    [Theory]
    [InlineData("multipart/form-data; boundary=abc", "abc")]
    [InlineData("multipart/form-data; boundary=\"a b\"", "a b")]
    [InlineData("application/x-www-form-urlencoded", null)]
    [InlineData(null, null)]
    public void ExtractsBoundaries(string? contentType, string? expected) =>
        Assert.Equal(expected, MultipartReader.GetBoundary(contentType));
}

public class FileOperationsTests
{
    [Fact]
    public async Task SaveRespectsOverwriteAndLeavesNoTemporaries()
    {
        using var dir = new TempDir();
        Assert.Equal(FileOpResult.Ok, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("uno"), false, default));
        Assert.Equal(FileOpResult.Exists, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("dos"), false, default));
        Assert.Equal("uno", File.ReadAllText(Path.Combine(dir.Path, "a.txt")));
        Assert.Equal(FileOpResult.Ok, await FileOperations.SaveAsync(dir.Path, "a.txt", Stream("dos"), true, default));
        Assert.Equal("dos", File.ReadAllText(Path.Combine(dir.Path, "a.txt")));
        Assert.Equal(FileOpResult.InvalidName, await FileOperations.SaveAsync(dir.Path, "..", Stream("x"), true, default));
        Assert.Single(Directory.GetFiles(dir.Path));
    }

    [Fact]
    public async Task PartsMustArriveInOrder()
    {
        using var dir = new TempDir();
        var r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 0, 6, Stream("abc"), false, default);
        Assert.Equal((FileOpResult.Ok, false), r);
        Assert.False(File.Exists(Path.Combine(dir.Path, "big.bin")));

        r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 1, 6, Stream("zzz"), false, default);
        Assert.Equal(FileOpResult.Conflict, r.Result);

        r = await FileOperations.WritePartAsync(dir.Path, "big.bin", "abcdef12", 3, 6, Stream("def"), false, default);
        Assert.Equal((FileOpResult.Ok, true), r);
        Assert.Equal("abcdef", File.ReadAllText(Path.Combine(dir.Path, "big.bin")));
        Assert.Single(Directory.GetFiles(dir.Path));
    }

    [Fact]
    public void FoldersRenameAndDelete()
    {
        using var dir = new TempDir();
        Assert.Equal(FileOpResult.Ok, FileOperations.CreateFolder(dir.Path, "nueva"));
        Assert.Equal(FileOpResult.Exists, FileOperations.CreateFolder(dir.Path, "nueva"));
        Assert.Equal(FileOpResult.InvalidName, FileOperations.CreateFolder(dir.Path, ".oculta"));
        dir.File("nueva/dentro.txt", "x");
        dir.File("otro.txt", "y");

        Assert.Equal(FileOpResult.Exists, FileOperations.Rename(Path.Combine(dir.Path, "nueva"), "otro.txt"));
        Assert.Equal(FileOpResult.Ok, FileOperations.Rename(Path.Combine(dir.Path, "nueva"), "vieja"));
        Assert.True(File.Exists(Path.Combine(dir.Path, "vieja", "dentro.txt")));
        Assert.Equal(FileOpResult.Ok, FileOperations.Delete(Path.Combine(dir.Path, "vieja")));
        Assert.Equal(FileOpResult.NotFound, FileOperations.Delete(Path.Combine(dir.Path, "vieja")));
    }

    private static MemoryStream Stream(string text) => new(Encoding.UTF8.GetBytes(text));
}

public class ZipStreamWriterTests
{
    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public async Task ProducesAValidZipOfTheAnnouncedLength(bool preferUtf8)
    {
        using var dir = new TempDir();
        dir.File("a.txt", "hola");
        dir.File("Año/b.txt", new string('x', 100_000));
        dir.File("日本/c.txt", "c");
        dir.Folder("vacia");
        dir.File(".oculto", "no");

        var zip = new ZipStreamWriter(ZipStreamWriter.Collect(dir.Path), preferUtf8);
        var ms = new MemoryStream();
        await zip.WriteAsync(ms, default);
        Assert.Equal(zip.TotalLength, ms.Length);

        ms.Position = 0;
        // Sin indicador UTF-8 los nombres van en CP437, como los leen los descompresores antiguos.
        using var archive = new ZipArchive(ms, ZipArchiveMode.Read, false, Encodings.Cp437Strict);
        var names = archive.Entries.Select(e => e.FullName).ToList();
        Assert.Contains("a.txt", names);
        Assert.Contains("Año/b.txt", names);
        Assert.Contains("日本/c.txt", names);
        Assert.Contains("vacia/", names);
        Assert.DoesNotContain(".oculto", names);
        using var reader = new StreamReader(archive.GetEntry("Año/b.txt")!.Open());
        Assert.Equal(100_000, reader.ReadToEnd().Length); // CRC comprobado por ZipArchive al leer
    }

    [Fact]
    public void NameEncodingDependsOnTheClient()
    {
        Assert.False(ZipStreamWriter.EncodeName("Año.txt", preferUtf8: false).Utf8);
        Assert.Equal(0xA4, ZipStreamWriter.EncodeName("ñ", preferUtf8: false).Name[0]); // ñ en CP437
        Assert.True(ZipStreamWriter.EncodeName("日本.txt", preferUtf8: false).Utf8);
        Assert.True(ZipStreamWriter.EncodeName("Año.txt", preferUtf8: true).Utf8);
        Assert.False(ZipStreamWriter.EncodeName("plain.txt", preferUtf8: true).Utf8);
    }

    [Fact]
    public void Crc32MatchesTheStandardCheckValue() =>
        Assert.Equal(0xCBF43926u, Crc32.Update(0, "123456789"u8));
}