>> = {
download
Baixar XML
+ } @else if (doc.dadosFiscaisExtraidos) {
+
+
+ Os dados desta nota foram lidos do XML, mas o arquivo não está mais disponível no
+ acervo. Os itens e impostos acima continuam válidos; apenas o download não é possível.
+
} @else {
A SEFAZ entregou apenas o resumo desta nota. O XML completo é liberado depois que a
diff --git a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
index d7131e5..a8d2694 100644
--- a/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
+++ b/tests/eContabil.Infrastructure.Tests/Storage/MinioFixture.cs
@@ -24,6 +24,10 @@ public sealed class MinioFixture : IAsyncLifetime
private const string Usuario = "minioadmin";
private const string Senha = "minioadmin123";
+ /// Credencial que a aplicação usa — restrita aos dois buckets, como em produção.
+ private const string ContaDeServico = "econtabil-app";
+ private const string SenhaDaConta = "conta-de-servico-do-teste";
+
private readonly MinioContainer _container = new MinioBuilder("minio/minio:latest")
.WithUsername(Usuario)
.WithPassword(Senha)
@@ -72,18 +76,77 @@ public async ValueTask InitializeAsync()
await CriarBucketAsync(Opcoes.Value.BucketXmls);
await CriarBucketAsync(Opcoes.Value.BucketCertificados);
+
+ await CriarContaDeServicoAsync();
+ }
+
+ ///
+ /// Cria a conta de serviço da aplicação com a mesma política restrita do docker-compose.
+ ///
+ ///
+ /// A aplicação não usa a credencial root em lugar nenhum: ela tem uma conta que só alcança objetos
+ /// nos dois buckets. Sem reproduzir isso aqui, a suíte provaria que o adaptador funciona com poderes
+ /// que ele não tem em produção — e apertar a política adiante quebraria a captura sem nenhum teste
+ /// vermelho para avisar.
+ ///
+ /// Os comandos rodam pelo mc que já vem na imagem do MinIO, porque o SDK .NET não expõe a API
+ /// de administração.
+ ///
+ private async Task CriarContaDeServicoAsync()
+ {
+ var politica = $$"""
+ {"Version":"2012-10-17","Statement":[
+ {"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],
+ "Resource":["arn:aws:s3:::{{Opcoes.Value.BucketXmls}}/*",
+ "arn:aws:s3:::{{Opcoes.Value.BucketCertificados}}/*"]},
+ {"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"],
+ "Resource":["arn:aws:s3:::{{Opcoes.Value.BucketXmls}}",
+ "arn:aws:s3:::{{Opcoes.Value.BucketCertificados}}"]}]}
+ """;
+
+ await ExecutarAsync("sh", "-c", $"printf '%s' '{politica.ReplaceLineEndings(" ")}' > /tmp/p.json");
+ await ExecutarAsync("mc", "alias", "set", "local", "http://127.0.0.1:9000", Usuario, Senha);
+ await ExecutarAsync("mc", "admin", "policy", "create", "local", "app", "/tmp/p.json");
+ await ExecutarAsync("mc", "admin", "user", "add", "local", ContaDeServico, SenhaDaConta);
+ await ExecutarAsync("mc", "admin", "policy", "attach", "local", "app", "--user", ContaDeServico);
+ }
+
+ private async Task ExecutarAsync(params string[] comando)
+ {
+ var resultado = await _container.ExecAsync(comando);
+
+ if (resultado.ExitCode != 0)
+ {
+ // Falha aqui não é ambiente ausente: o contêiner subiu. É defeito do próprio preparo, e
+ // engolir deixaria os testes rodando com uma política que não é a de produção.
+ throw new InvalidOperationException(
+ $"Preparo da conta de serviço falhou ({string.Join(' ', comando)}): {resultado.Stderr}");
+ }
}
///
- /// Monta o adaptador com o pipeline de resiliência real, como a aplicação o registra.
+ /// Monta o adaptador como a aplicação o monta: conta de serviço restrita e pipeline real.
///
///
/// Pipeline de verdade, e não ResiliencePipeline.Empty: é o registro real que decide o que é
/// falha transitória, e testar com um pipeline vazio provaria que o adaptador funciona numa
- /// configuração que nunca roda em lugar nenhum.
+ /// configuração que nunca roda em lugar nenhum. Vale o mesmo para a credencial —
+ /// é root e serve ao preparo dos testes, mas o adaptador recebe a conta restrita.
///
public MinioXmlStorage CriarStorage() =>
- new(Cliente, Opcoes, _pipelines ??= MontarPipelines());
+ new(ClienteDaAplicacao, Opcoes, _pipelines ??= MontarPipelines());
+
+ /// O cliente com a credencial restrita, como a aplicação o recebe.
+ public IMinioClient ClienteDaAplicacao => _clienteDaAplicacao ??= ClienteRestrito();
+
+ private IMinioClient? _clienteDaAplicacao;
+
+ private IMinioClient ClienteRestrito() =>
+ new MinioClient()
+ .WithEndpoint(Opcoes.Value.Endpoint)
+ .WithCredentials(ContaDeServico, SenhaDaConta)
+ .WithSSL(false)
+ .Build();
private ResiliencePipelineProvider? _pipelines;
diff --git a/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs b/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs
new file mode 100644
index 0000000..756fd7d
--- /dev/null
+++ b/tests/eContabil.Infrastructure.Tests/Storage/PoliticaDaContaDeServicoTests.cs
@@ -0,0 +1,78 @@
+using Minio.DataModel.Args;
+using Xunit;
+
+namespace eContabil.Infrastructure.Tests.Storage;
+
+///
+/// A conta que a aplicação usa alcança objetos nos dois buckets, e nada além disso.
+///
+///
+/// A outra metade do teste de armazenamento. Os testes do adaptador provam que a conta **consegue**
+/// gravar e ler; estes provam que ela **não consegue** o resto. Sem eles, alargar a política para
+/// s3:* deixaria a suíte inteira verde — e a aplicação voltaria a ter administração do object
+/// store sem que nenhuma linha de código mudasse.
+///
+[Collection(nameof(MinioTestes))]
+public class PoliticaDaContaDeServicoTests(MinioFixture minio)
+{
+ [Fact]
+ public async Task ContaDaAplicacao_NaoCriaBucket()
+ {
+ // Quem provisiona é quem tem a credencial de administração. A aplicação apenas confere.
+ if (minio.MotivoIndisponivel is { } motivo)
+ {
+ Assert.Skip(motivo);
+ return;
+ }
+
+ await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.MakeBucketAsync(
+ new MakeBucketArgs().WithBucket("bucket-que-a-aplicacao-nao-deveria-criar"),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ContaDaAplicacao_NaoApagaBucket()
+ {
+ // É o comando que esvazia o acervo antes de remover o bucket. Negá-lo é a diferença entre um
+ // engano custar um objeto e custar cinco anos de guarda fiscal.
+ if (minio.MotivoIndisponivel is { } motivo)
+ {
+ Assert.Skip(motivo);
+ return;
+ }
+
+ await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.RemoveBucketAsync(
+ new RemoveBucketArgs().WithBucket(minio.Opcoes.Value.BucketXmls),
+ TestContext.Current.CancellationToken));
+
+ // E o bucket continua lá.
+ Assert.True(await minio.Cliente.BucketExistsAsync(
+ new BucketExistsArgs().WithBucket(minio.Opcoes.Value.BucketXmls),
+ TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ContaDaAplicacao_NaoAlcancaBucketDeFora()
+ {
+ // A política nomeia os dois buckets. Um terceiro — criado por outro sistema no mesmo MinIO —
+ // fica fora do alcance da aplicação.
+ if (minio.MotivoIndisponivel is { } motivo)
+ {
+ Assert.Skip(motivo);
+ return;
+ }
+
+ const string alheio = "bucket-de-outro-sistema";
+
+ if (!await minio.Cliente.BucketExistsAsync(
+ new BucketExistsArgs().WithBucket(alheio), TestContext.Current.CancellationToken))
+ {
+ await minio.Cliente.MakeBucketAsync(
+ new MakeBucketArgs().WithBucket(alheio), TestContext.Current.CancellationToken);
+ }
+
+ await Assert.ThrowsAnyAsync(() => minio.ClienteDaAplicacao.StatObjectAsync(
+ new StatObjectArgs().WithBucket(alheio).WithObject("qualquer.txt"),
+ TestContext.Current.CancellationToken));
+ }
+}