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));
}