using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Net;
using Sunfish.Qr;
using Xunit;
namespace Sunfish.Tests;
public class AddressRulesTests
{
[Theory]
[InlineData("127.0.0.1", "127.0.0.1", true)]
[InlineData("127.0.0.1", "127.0.0.2", false)]
[InlineData("192.168.1.0/24", "192.168.1.77", true)]
[InlineData("192.168.1.0/24", "192.168.2.1", false)]
[InlineData("10.0.0.0/8", "10.200.3.4", true)]
[InlineData("172.16.0.0/12", "172.31.255.1", true)]
[InlineData("172.16.0.0/12", "172.32.0.1", false)]
[InlineData("::1", "::1", true)]
[InlineData("fd00::/8", "fd12:3456::1", true)]
[InlineData("fd00::/8", "fe80::1", false)]
[InlineData("127.0.0.1", "::ffff:127.0.0.1", true)] // IPv4 vista como IPv6
[InlineData("*", "203.0.113.9", true)]
public void MatchesAddressesAndNetworks(string rule, string address, bool expected) =>
Assert.Equal(expected, AddressRules.Matches([rule], IPAddress.Parse(address)));
[Theory]
[InlineData("192.168.1.0/24", true)]
[InlineData("::1", true)]
[InlineData("*", true)]
[InlineData("192.168.1.0/33", false)]
[InlineData("localhost", false)]
[InlineData("", false)]
public void ValidatesRules(string rule, bool valid) => Assert.Equal(valid, AddressRules.IsValid(rule));
}
public class LegacyImportTests
{
[Fact]
public void ImportsTheV2Configuration()
{
using var dir = new TempDir();
string json = $$"""
{
"Port": 9090, "Active": true, "SunfishRoot": false, "AdminPanel": true, "AdminPwd": "1234",
"Services": [
{ "Type": "WebService", "Name": "Fotos", "Enabled": true, "Location": "fotos",
"Settings": { "path": "{{dir.Path.Replace("\\", "\\\\")}}", "defaultFile": "index.html", "folderShare": true, "readOnly": false, "allowDeletion": true, "allowServerExecution": true } },
{ "Type": "WebService", "Name": "Solo lectura", "Enabled": false, "Location": "/docs",
"Settings": [ { "Key": "path", "Value": "{{dir.Path.Replace("\\", "\\\\")}}" }, { "Key": "readOnly", "Value": true }, { "Key": "allowDeletion", "Value": true } ] },
{ "Type": "Sunfish.Plugins.HelloWorld", "Name": "Hola" }
]
}
""";
var notes = new List<string>();
var config = LegacyImport.FromV2(json, notes);
Assert.NotNull(config);
Assert.Equal(9090, config.Server.HttpPort);
Assert.False(config.Server.RootMenu);
Assert.False(config.Admin.Enabled);
Assert.Equal(2, config.Shares.Count);
var fotos = config.Shares[0];
Assert.Equal("Fotos", fotos.Name);
Assert.Equal("/fotos", fotos.Location);
Assert.Equal(dir.Path, fotos.Path);
Assert.Equal("index.html", fotos.Index);
Assert.True(fotos.Listing && fotos.Upload && fotos.Overwrite && fotos.Rename && fotos.Delete);
var ro = config.Shares[1];
Assert.Equal("Sololectura", ro.Name); // sin espacios: no valen en el INI
Assert.True(ShareSettings.IsValidName(ro.Name));
Assert.False(ro.Enabled);
Assert.False(ro.Upload || ro.Overwrite || ro.Rename || ro.Delete); // solo lectura: tampoco borra
Assert.Equal(3, notes.Count); // panel, ejecucion y el servicio que no es web
Assert.Null(SunfishConfig.Parse(config.ToText()).Errors.FirstOrDefault());
}
[Fact]
public void LowPortsAreReplaced()
{
var notes = new List<string>();
var config = LegacyImport.FromV2("{\"Port\": 90}", notes);
Assert.Equal(8080, config!.Server.HttpPort);
Assert.Single(notes);
}
[Fact]
public void RejectsNonJson() => Assert.Null(LegacyImport.FromV2("[Server]\nName = x", []));
}
public class QrImageTests
{
[Fact]
public void PngHasTheExpectedSize()
{
var qr = QrCode.Encode("http://10.0.0.1:8080/");
var png = QrImage.Png(qr, 3);
Assert.Equal(new byte[] { 0x89, (byte)'P', (byte)'N', (byte)'G' }, png[..4]);
int width = (png[16] << 24) | (png[17] << 16) | (png[18] << 8) | png[19];
Assert.Equal((qr.Size + 8) * 3, width);
}
[Fact]
public void HalfBlocksCoverTheWholeCode()
{
var qr = QrCode.Encode("http://10.0.0.1:8080/");
var lines = QrImage.HalfBlocks(qr, 2).ToList();
Assert.Equal((qr.Size + 4 + 1) / 2, lines.Count);
Assert.All(lines, l => Assert.Equal(qr.Size + 4, l.Length));
}
}
/// <summary>Panel de administracion contra el servidor real.</summary>
public class AdminServerTests : IAsyncLifetime
{
private readonly TempDir _dir = new();
private readonly TempDir _other = new();
private TestServer _server = null!;
private static readonly string Auth = "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:secreto")) + "\r\n";
public Task InitializeAsync()
{
var config = new SunfishConfig();
config.Admin.Enabled = true;
config.Admin.PasswordHash = Passwords.Hash("secreto", 1000);
config.Users.Add(new UserConfig { Name = "ana", PasswordHash = Passwords.Hash("1234", 1000) });
config.Shares.Add(new ShareConfig { Name = "pub", Location = "/pub", Path = _dir.Path, Users = ["ana"] });
_server = new TestServer(config);
return Task.CompletedTask;
}
public async Task DisposeAsync()
{
await _server.DisposeAsync();
_dir.Dispose();
_other.Dispose();
}
private SunfishConfig Config => _server.Host.Config;
private async Task<string> TokenAsync(string action)
{
var page = await _server.GetAsync(action == "share-save" ? "/$sunfish/admin/share" : "/$sunfish/admin/", extraHeaders: Auth);
var m = Regex.Match(page.BodyText, $"NAME=\"a\" VALUE=\"{action}\"><INPUT TYPE=HIDDEN NAME=\"t\" VALUE=\"([^\"]+)\"");
Assert.True(m.Success, "no form for " + action);
return m.Groups[1].Value;
}
private async Task<RawResponse> PostAsync(string action, string fields, string? token = null)
{
token ??= await TokenAsync(action);
string body = $"a={action}&t={Uri.EscapeDataString(token)}&{fields}";
return await _server.SendAsync("POST /$sunfish/admin/ HTTP/1.0\r\n" + Auth + "Content-Type: application/x-www-form-urlencoded\r\n" +
$"Content-Length: {Encoding.Latin1.GetByteCount(body)}\r\n\r\n{body}");
}
[Fact]
public async Task RequiresThePassword()
{
var none = await _server.GetAsync("/$sunfish/admin/");
Assert.Equal(401, none.Status);
Assert.Contains("admin", none.Headers["WWW-Authenticate"]);
var wrong = await _server.GetAsync("/$sunfish/admin/", extraHeaders: "Authorization: Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:no")) + "\r\n");
Assert.Equal(401, wrong.Status);
var ok = await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth);
Assert.Equal(200, ok.Status);
Assert.Contains("/pub", ok.BodyText);
Assert.Contains("qr?u=http", ok.BodyText.Replace("%3A", ":"), StringComparison.Ordinal);
Assert.DoesNotContain("<SCRIPT", ok.BodyText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task DisabledOrNotAllowedIsHidden()
{
var copy = Config.Clone();
copy.Admin.AllowFrom = ["10.0.0.0/8"];
await _server.Host.ApplyAsync(copy);
Assert.Equal(403, (await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth)).Status);
copy = Config.Clone();
copy.Admin.Enabled = false;
await _server.Host.ApplyAsync(copy);
Assert.Equal(404, (await _server.GetAsync("/$sunfish/admin/", extraHeaders: Auth)).Status);
}
[Fact]
public async Task ChangesTheServerSettings()
{
var r = await PostAsync("server", "name=Prueba&ftpport=2999&rootmenu=1");
Assert.Equal(302, r.Status);
Assert.Equal("Prueba", Config.Server.Name);
Assert.False(Config.Server.Ftp);
Assert.Equal(2999, Config.Server.FtpPort);
Assert.False(_server.Host.FtpRunning);
Assert.True(_server.Host.HttpRunning);
}
[Fact]
public async Task InvalidTokenChangesNothing()
{
var r = await PostAsync("server", "name=Malo&ftpport=2999", token: "falso");
Assert.Equal(200, r.Status);
Assert.NotEqual("Malo", Config.Server.Name);
}
[Fact]
public async Task AddsEditsAndDeletesShares()
{
string path = Uri.EscapeDataString(_other.Path);
var bad = await PostAsync("share-save", "orig=&name=nuevo&location=%2Fnuevo&path=C%3A%5Cno%5Cexiste&enabled=1");
Assert.Equal(200, bad.Status);
Assert.DoesNotContain(Config.Shares, s => s.Name == "nuevo");
var r = await PostAsync("share-save", $"orig=&name=nuevo&location=%2Fnuevo&path={path}&index=&enabled=1&listing=1&upload=1&u.ana=1");
Assert.Equal(302, r.Status);
var share = Assert.Single(Config.Shares, s => s.Name == "nuevo");
Assert.True(share.Upload);
Assert.False(share.Delete);
Assert.Equal(["ana"], share.Users);
Assert.Equal(200, (await _server.GetAsync("/$sunfish/admin/share?n=nuevo", extraHeaders: Auth)).Status);
r = await PostAsync("share-save", $"orig=nuevo&name=renombrado&location=%2Fnuevo&path={path}&enabled=1");
Assert.Equal(302, r.Status);
Assert.Contains(Config.Shares, s => s.Name == "renombrado" && !s.Upload && s.Users.Count == 0);
Assert.DoesNotContain(Config.Shares, s => s.Name == "nuevo");
r = await PostAsync("share-del", "n=renombrado");
Assert.Equal(302, r.Status);
Assert.DoesNotContain(Config.Shares, s => s.Name == "renombrado");
}
[Fact]
public async Task ManagesUsers()
{
Assert.Equal(200, (await PostAsync("user-add", "name=luis&p1=a&p2=b")).Status); // no coinciden
Assert.DoesNotContain(Config.Users, u => u.Name == "luis");
Assert.Equal(302, (await PostAsync("user-add", "name=luis&p1=clave&p2=clave")).Status);
Assert.True(Passwords.Verify("clave", Config.Users.Single(u => u.Name == "luis").PasswordHash));
Assert.Equal(302, (await PostAsync("user-pass", "name=luis&p1=otra&p2=otra")).Status);
Assert.True(Passwords.Verify("otra", Config.Users.Single(u => u.Name == "luis").PasswordHash));
Assert.Equal(200, (await PostAsync("user-add", "name=admin&p1=x&p2=x")).Status); // reservado
Assert.Equal(302, (await PostAsync("user-del", "name=ana")).Status);
Assert.DoesNotContain(Config.Users, u => u.Name == "ana");
Assert.Empty(Config.Shares.Single(s => s.Name == "pub").Users);
}
[Fact]
public async Task PanelSettingsNeedTheCurrentPasswordAndCannotLockOut()
{
Assert.Equal(200, (await PostAsync("admin", "allow=127.0.0.1&cur=mala")).Status);
Assert.Equal(200, (await PostAsync("admin", "allow=10.0.0.0%2F8&cur=secreto")).Status);
Assert.Contains("127.0.0.1", Config.Admin.AllowFrom);
Assert.Equal(200, (await PostAsync("admin", "allow=nada&cur=secreto")).Status);
Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1%2C+192.168.0.0%2F16&cur=secreto")).Status);
Assert.Equal(["127.0.0.1", "192.168.0.0/16"], Config.Admin.AllowFrom);
Assert.Equal(302, (await PostAsync("admin", "allow=127.0.0.1&cur=secreto&p1=nueva&p2=nueva")).Status);
Assert.True(Passwords.Verify("nueva", Config.Admin.PasswordHash));
}
[Fact]
public async Task ServesQrAndLog()
{
var qr = await _server.GetAsync("/$sunfish/admin/qr?u=" + Uri.EscapeDataString("http://10.0.0.1:8080/"), extraHeaders: Auth);
Assert.Equal("image/png", qr.Headers["Content-Type"]);
Assert.Equal(404, (await _server.GetAsync("/$sunfish/admin/qr?u=javascript:alert(1)", extraHeaders: Auth)).Status);
var log = await _server.GetAsync("/$sunfish/admin/log", extraHeaders: Auth);
Assert.Contains("<PRE>", log.BodyText);
}
}