Skip to content
1 change: 1 addition & 0 deletions NEXT_RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Redesigned item sharing so unrestricted links are supported and the created share URL is returned to the caller.
- Trimmed the CLI version string returned on Windows so `Version` no longer includes a trailing newline.
- Clarified in the docs that `GetItems(...)` returns summary items and `GetItem(...)` should be used before working with hydrated fields.
- Added `FileAttachments` support so item attachments can be created, listed, removed, and read through the wrapper.
- Added regression coverage for archive behavior, item sharing, version handling, and adding a new field to an existing built-in item.

## Migration
Expand Down
35 changes: 33 additions & 2 deletions OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
using System.Runtime.InteropServices;
using OnePassword.Common;
using OnePassword.Documents;
using OnePassword.Items;
using OnePassword.Vaults;

namespace OnePassword;
Expand Down Expand Up @@ -77,6 +76,38 @@ public void ArchiveItemStringOverloadUsesArchiveCommand()
Assert.That(fakeCli.LastArguments, Does.StartWith("item delete item-id --vault vault-id --archive"));
}

[Test]
public void GetFileAttachmentReferenceUsesIds()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();

var reference = manager.GetFileAttachmentReference("file-id", "item-id", "vault-id");

Assert.That(reference, Is.EqualTo("op://vault-id/item-id/file-id?attr=content"));
}

[Test]
public void SaveFileAttachmentContentUsesGeneratedReference()
{
using var fakeCli = new FakeCli();
var manager = fakeCli.CreateManager();
var outputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

try
{
manager.SaveFileAttachmentContent("file-id", "item-id", "vault-id", outputPath);

Assert.That(fakeCli.LastArguments, Does.StartWith("read op://vault-id/item-id/file-id?attr=content --no-newline --force --out-file "));
Assert.That(fakeCli.LastArguments, Does.Contain(outputPath));
}
finally
{
if (File.Exists(outputPath))
File.Delete(outputPath);
}
}

[Test]
public void MoveItemStringOverloadUsesResolvedVaultIds()
{
Expand Down Expand Up @@ -435,7 +466,7 @@ private sealed class TestDocument(string id) : IDocument
public string Id { get; } = id;
}

private sealed class TestItem(string id) : IItem
private sealed class TestItem(string id) : OnePassword.Items.IItem
{
public string Id { get; } = id;
}
Expand Down
71 changes: 70 additions & 1 deletion OnePassword.NET.Tests/TestItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ public class TestItems : TestsBase
{
private const string InitialTitle = "Created Item";
private const string InitialUsername = "Created Username";
private const string InitialAttachmentName = "Initial Attachment";
private const string InitialAttachmentContent = "Initial attachment content";
private const string EditSection = "Edit Section";
private const string EditField = "Edit Field";
private const string DeleteSection = "Delete Section";
Expand All @@ -23,8 +25,13 @@ public class TestItems : TestsBase
private const string FinalValue = "Test Value";
private const string AddedField = "Added Field";
private const string AddedValue = "Added Value";
private const string AddedAttachmentName = "Added Attachment";
private const string AddedAttachmentContent = "Added attachment content";
private const string Tag1 = "Tag1";
private const string Tag2 = "Tag2";
private readonly string _addedAttachmentFilePath = Path.Combine(WorkingDirectory, "AddedAttachment.txt");
private readonly string _addedAttachmentOutputFilePath = Path.Combine(WorkingDirectory, "AddedAttachment.out.txt");
private readonly string _initialAttachmentFilePath = Path.Combine(WorkingDirectory, "InitialAttachment.txt");

[Test]
[Order(1)]
Expand All @@ -40,6 +47,8 @@ public void CreateItem()
template.Fields.First(x => x.Label == "username").Value = InitialUsername;
template.Tags.Add(Tag1);
template.Tags.Add(Tag2);
File.WriteAllText(_initialAttachmentFilePath, InitialAttachmentContent);
template.FileAttachments.Add(new FileAttachment(_initialAttachmentFilePath, InitialAttachmentName));

var editSection = new Section(EditSection);
template.Sections.Add(editSection);
Expand All @@ -60,6 +69,9 @@ public void CreateItem()
Assert.That(_initialItem.Fields.First(x => x.Section?.Label == EditSection && x.Label == EditField).Type, Is.EqualTo(InitialType));
Assert.That(_initialItem.Fields.First(x => x.Section?.Label == EditSection && x.Label == EditField).Value, Is.EqualTo(InitialValue));
Assert.That(_initialItem.Fields.First(x => x.Section?.Label == DeleteSection && x.Label == DeleteField).Value, Is.EqualTo(DeleteValue));
Assert.That(_initialItem.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.True);
Assert.That(_initialItem.FileAttachments.First(x => x.Name == InitialAttachmentName).ContentPath, Is.Not.Empty);
Assert.That(_initialItem.FileAttachments.First(x => x.Name == InitialAttachmentName).Size, Is.GreaterThan(0));
Assert.That(_initialItem.Tags, Does.Contain(Tag1));
Assert.That(_initialItem.Tags, Does.Contain(Tag2));
});
Expand Down Expand Up @@ -93,6 +105,7 @@ public void EditItem()
Assert.That(item.Fields.First(x => x.Section?.Label == EditSection && x.Label == EditField).Type, Is.EqualTo(FinalType));
Assert.That(item.Fields.First(x => x.Section?.Label == EditSection && x.Label == EditField).Value, Is.EqualTo(FinalValue));
Assert.That(item.Fields.FirstOrDefault(x => x.Section?.Label == DeleteSection && x.Label == DeleteField), Is.Null);
Assert.That(item.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.True);
Assert.That(item.Tags, Does.Contain(Tag1));
Assert.That(item.Tags, Does.Not.Contain(Tag2));
});
Expand All @@ -119,6 +132,56 @@ public void EditItemAddsNewField()

[Test]
[Order(4)]
public void EditItemAddsFileAttachment()
{
if (!RunLiveTests)
Assert.Ignore();

Run(RunType.Test, () =>
{
File.WriteAllText(_addedAttachmentFilePath, AddedAttachmentContent);

var item = OnePassword.GetItem(_initialItem, TestVault);
item.FileAttachments.Add(new FileAttachment(_addedAttachmentFilePath, AddedAttachmentName));

var editedItem = OnePassword.EditItem(item, TestVault);

Assert.Multiple(() =>
{
Assert.That(editedItem.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.True);
Assert.That(editedItem.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True);
});

_initialItem = editedItem;
});
}

[Test]
[Order(5)]
public void EditItemRemovesFileAttachment()
{
if (!RunLiveTests)
Assert.Ignore();

Run(RunType.Test, () =>
{
var item = OnePassword.GetItem(_initialItem, TestVault);
item.FileAttachments.Remove(item.FileAttachments.First(x => x.Name == InitialAttachmentName));

var editedItem = OnePassword.EditItem(item, TestVault);

Assert.Multiple(() =>
{
Assert.That(editedItem.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.False);
Assert.That(editedItem.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True);
});

_initialItem = editedItem;
});
}

[Test]
[Order(6)]
public void GetItems()
{
if (!RunLiveTests)
Expand All @@ -143,7 +206,7 @@ public void GetItems()
}

[Test]
[Order(5)]
[Order(7)]
public void GetItem()
{
if (!RunLiveTests)
Expand All @@ -162,7 +225,13 @@ public void GetItem()
Assert.That(item.Fields.First(x => x.Section?.Label == EditSection && x.Label == EditField).Value, Is.EqualTo(FinalValue));
Assert.That(item.Fields.First(x => x.Label == AddedField).Value, Is.EqualTo(AddedValue));
Assert.That(item.Fields.FirstOrDefault(x => x.Section?.Label == DeleteSection && x.Label == DeleteField), Is.Null);
Assert.That(item.FileAttachments.Any(x => x.Name == InitialAttachmentName), Is.False);
Assert.That(item.FileAttachments.Any(x => x.Name == AddedAttachmentName), Is.True);
});

var attachment = item.FileAttachments.First(x => x.Name == AddedAttachmentName);
OnePassword.SaveFileAttachmentContent(attachment, item, TestVault, _addedAttachmentOutputFilePath);
Assert.That(File.ReadAllText(_addedAttachmentOutputFilePath), Is.EqualTo(AddedAttachmentContent));
});
}
}
34 changes: 34 additions & 0 deletions OnePassword.NET/IOnePasswordManager.Items.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,40 @@ public ImmutableList<Item> SearchForItems(string? vaultId = null, bool? includeA
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public Item EditItem(Item item, string vaultId);

/// <summary>Builds a secret reference for the specified file attachment's content.</summary>
/// <param name="fileAttachment">The file attachment.</param>
/// <param name="item">The item that contains the file attachment.</param>
/// <param name="vault">The vault that contains the item.</param>
/// <returns>The secret reference for the file attachment content.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public string GetFileAttachmentReference(FileAttachment fileAttachment, IItem item, IVault vault);

/// <summary>Builds a secret reference for the specified file attachment's content.</summary>
/// <param name="fileAttachmentId">The file attachment ID.</param>
/// <param name="itemId">The item ID.</param>
/// <param name="vaultId">The vault ID.</param>
/// <returns>The secret reference for the file attachment content.</returns>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public string GetFileAttachmentReference(string fileAttachmentId, string itemId, string vaultId);

/// <summary>Saves a file attachment's content to disk.</summary>
/// <param name="fileAttachment">The file attachment.</param>
/// <param name="item">The item that contains the file attachment.</param>
/// <param name="vault">The vault that contains the item.</param>
/// <param name="filePath">The output file path.</param>
/// <param name="fileMode">The file mode to use when creating the file.</param>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public void SaveFileAttachmentContent(FileAttachment fileAttachment, IItem item, IVault vault, string filePath, string? fileMode = null);

/// <summary>Saves a file attachment's content to disk.</summary>
/// <param name="fileAttachmentId">The file attachment ID.</param>
/// <param name="itemId">The item ID.</param>
/// <param name="vaultId">The vault ID.</param>
/// <param name="filePath">The output file path.</param>
/// <param name="fileMode">The file mode to use when creating the file.</param>
/// <exception cref="ArgumentException">Thrown when there is an invalid argument.</exception>
public void SaveFileAttachmentContent(string fileAttachmentId, string itemId, string vaultId, string filePath, string? fileMode = null);

/// <summary>Archives an item.</summary>
/// <param name="item">The item to archive.</param>
/// <param name="vault">The vault that contains the item to archive.</param>
Expand Down
56 changes: 56 additions & 0 deletions OnePassword.NET/Items/FileAttachment.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using OnePassword.Common;

namespace OnePassword.Items;

/// <summary>Represents a file attachment associated with an item.</summary>
public sealed class FileAttachment : ITracked
{
/// <summary>The section where the attachment is stored.</summary>
[JsonInclude]
[JsonPropertyName("section")]
public Section? Section { get; internal set; }

/// <summary>The attachment ID.</summary>
[JsonInclude]
[JsonPropertyName("id")]
public string Id { get; internal set; } = "";

/// <summary>The attachment name.</summary>
[JsonInclude]
[JsonPropertyName("name")]
public string Name { get; internal set; } = "";

/// <summary>The attachment size in bytes.</summary>
[JsonInclude]
[JsonPropertyName("size")]
public int Size { get; internal set; }

/// <summary>The path used to access the attachment content.</summary>
[JsonInclude]
[JsonPropertyName("content_path")]
public string ContentPath { get; internal set; } = "";

/// <inheritdoc />
public bool Changed => false;

/// <summary>Initializes a new instance of <see cref="FileAttachment" />.</summary>
public FileAttachment()
{
}

/// <summary>Initializes a new instance of <see cref="FileAttachment" /> to attach a local file when creating or editing an item.</summary>
/// <param name="filePath">The local file path to attach.</param>
/// <param name="name">The attachment name. Leave empty to preserve the source file name.</param>
/// <param name="section">The section where the attachment should be added.</param>
public FileAttachment(string filePath, string? name = null, Section? section = null)
{
ContentPath = filePath ?? "";
Name = name ?? "";
Section = section;
}

/// <inheritdoc />
void ITracked.AcceptChanges()
{
}
}
11 changes: 10 additions & 1 deletion OnePassword.NET/Items/ItemBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ public string CategoryId
[JsonPropertyName("tags")]
public TrackedList<string> Tags { get; internal set; } = [];

/// <summary>
/// The file attachments associated with the item.
/// </summary>
[JsonInclude]
[JsonPropertyName("files")]
public TrackedList<FileAttachment> FileAttachments { get; internal set; } = [];

/// <summary>
/// Returns <see langword="true" /> when the title has changed, <see langword="false" /> otherwise.
/// </summary>
Expand All @@ -91,7 +98,8 @@ public string CategoryId
|| ((ITracked)Sections).Changed
|| ((ITracked)Fields).Changed
|| ((ITracked)Urls).Changed
|| ((ITracked)Tags).Changed;
|| ((ITracked)Tags).Changed
|| ((ITracked)FileAttachments).Changed;

/// <inheritdoc />
void ITracked.AcceptChanges()
Expand All @@ -102,5 +110,6 @@ void ITracked.AcceptChanges()
((ITracked)Fields).AcceptChanges();
((ITracked)Urls).AcceptChanges();
((ITracked)Tags).AcceptChanges();
((ITracked)FileAttachments).AcceptChanges();
}
}
Loading
Loading