Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ This repository contains `OnePassword.NET`, a .NET wrapper for the 1Password CLI
- Use the GitHub CLI (`gh`) for GitHub-related operations whenever possible.
- Prefer `gh` for pull request workflows, including creating, viewing, checking out, and reviewing pull requests.
- If `gh` is unavailable or unauthenticated, note that clearly before falling back to another approach.

## Generated Assets

- Do not read, search, or summarize generated documentation/site assets unless the user explicitly asks for them.
- In particular, avoid generated docfx output and bundled vendor assets such as minified JavaScript, CSS, or copied third-party files; prefer the markdown and source files under `docfx/` instead.
29 changes: 29 additions & 0 deletions NEXT_RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Draft release description for x.x.x

## Breaking changes

- `ShareItem(...)` now returns `ItemShareResult` instead of `void`.
- The single-email `ShareItem(...)` overloads were removed. Use a collection of email addresses for restricted links, or omit the collection entirely for unrestricted links.

## Highlights

- Merged PR `#92` to fix `ArchiveDocument(IDocument, IVault)` so it archives documents instead of deleting them.
- 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 regression coverage for archive behavior, item sharing, version handling, and adding a new field to an existing built-in item.

## Migration

Before:

```csharp
onePassword.ShareItem(item, vault, "recipient@example.com");
```

After:

```csharp
var restrictedShare = onePassword.ShareItem(item, vault, new[] { "recipient@example.com" });
var unrestrictedShare = onePassword.ShareItem(item, vault);
```
285 changes: 285 additions & 0 deletions OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
using System.Globalization;
using System.Runtime.InteropServices;
using OnePassword.Common;
using OnePassword.Documents;
using OnePassword.Items;
using OnePassword.Vaults;

namespace OnePassword;

[TestFixture]
public class OnePasswordManagerCommandTests
{
private static readonly string[] ParsedRecipients = ["one@example.com", "two@example.com"];

[Test]
public void VersionIsTrimmed()
{
using var fakeCli = new FakeCli(versionOutput: "2.32.1\r\n");

var manager = fakeCli.CreateManager();

Assert.That(manager.Version, Is.EqualTo("2.32.1"));
}

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

manager.ArchiveDocument(new TestDocument("document-id"), new TestVault("vault-id"));

Assert.That(fakeCli.LastArguments, Does.StartWith("document delete document-id --vault vault-id --archive"));
}

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

manager.ArchiveDocument("document-id", "vault-id");

Assert.That(fakeCli.LastArguments, Does.StartWith("document delete document-id --vault vault-id --archive"));
}

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

manager.ArchiveItem(new TestItem("item-id"), new TestVault("vault-id"));

Assert.That(fakeCli.LastArguments, Does.StartWith("item delete item-id --vault vault-id --archive"));
}

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

manager.ArchiveItem("item-id", "vault-id");

Assert.That(fakeCli.LastArguments, Does.StartWith("item delete item-id --vault vault-id --archive"));
}

[Test]
public void ShareItemWithoutEmailsOmitsEmailsFlag()
{
using var fakeCli = new FakeCli(nextOutput: "https://share.example/item\r\n");
var manager = fakeCli.CreateManager();

var result = manager.ShareItem("item-id", "vault-id");

Assert.Multiple(() =>
{
Assert.That(result.Url, Is.EqualTo(new Uri("https://share.example/item")));
Assert.That(result.RawResponse, Is.EqualTo("https://share.example/item"));
Assert.That(fakeCli.LastArguments, Does.StartWith("item share item-id --vault vault-id"));
Assert.That(fakeCli.LastArguments, Does.Not.Contain("--emails"));
});
}

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

manager.ShareItem("item-id", "vault-id", ["recipient@example.com"]);

Assert.That(fakeCli.LastArguments, Does.Contain("--emails recipient@example.com"));
}

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

manager.ShareItem("item-id", "vault-id", ["one@example.com", "two@example.com"]);

Assert.That(fakeCli.LastArguments, Does.Contain("--emails one@example.com,two@example.com"));
}

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

manager.ShareItem("item-id", "vault-id", Array.Empty<string>());

Assert.That(fakeCli.LastArguments, Does.Not.Contain("--emails"));
}

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

manager.ShareItem("item-id", "vault-id", expiresIn: TimeSpan.FromDays(7));

Assert.That(fakeCli.LastArguments, Does.Contain("--expires-in 7d"));
}

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

manager.ShareItem("item-id", "vault-id", viewOnce: true);

Assert.That(fakeCli.LastArguments, Does.Contain("--view-once"));
}

[Test]
public void ShareItemParsesStructuredShareResult()
{
const string response = """
{
"share_link": "https://share.example/item",
"expires_at": "2026-03-15T12:00:00Z",
"view_once": true,
"recipients": [
{ "email": "one@example.com" },
{ "address": "two@example.com" }
]
}
""";

using var fakeCli = new FakeCli(nextOutput: response);
var manager = fakeCli.CreateManager();

var result = manager.ShareItem("item-id", "vault-id");

Assert.Multiple(() =>
{
Assert.That(result.Url, Is.EqualTo(new Uri("https://share.example/item")));
Assert.That(result.ExpiresAt, Is.EqualTo(DateTimeOffset.Parse("2026-03-15T12:00:00Z", CultureInfo.InvariantCulture)));
Assert.That(result.ViewOnce, Is.True);
Assert.That(result.Recipients, Is.EqualTo(ParsedRecipients));
Assert.That(result.RawResponse, Does.Contain("\"share_link\": \"https://share.example/item\""));
});
}

private sealed class FakeCli : IDisposable
{
private readonly string _argumentsPath;
private readonly string _directoryPath;
private readonly string _nextOutputPath;
private readonly string _versionOutputPath;

public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}")
{
_directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
_argumentsPath = Path.Combine(_directoryPath, "last-arguments.txt");
_nextOutputPath = Path.Combine(_directoryPath, "next-output.txt");
_versionOutputPath = Path.Combine(_directoryPath, "version-output.txt");

Directory.CreateDirectory(_directoryPath);
File.WriteAllText(_nextOutputPath, nextOutput);
File.WriteAllText(_versionOutputPath, versionOutput);

var executablePath = Path.Combine(_directoryPath, ExecutableName);
File.WriteAllText(executablePath, GetScript());
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
File.SetUnixFileMode(executablePath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}
}

public string ExecutableName { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.cmd" : "op";

public string LastArguments => File.Exists(_argumentsPath) ? File.ReadAllText(_argumentsPath) : "";

public OnePasswordManager CreateManager()
{
return new OnePasswordManager(options =>
{
options.Path = _directoryPath;
options.Executable = ExecutableName;
options.ServiceAccountToken = "test-token";
});
}

public void Dispose()
{
if (Directory.Exists(_directoryPath))
Directory.Delete(_directoryPath, true);
}

private static string GetScript()
{
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? """
@echo off
setlocal
> "%~dp0last-arguments.txt" echo %*
if "%~1"=="--version" (
type "%~dp0version-output.txt"
exit /b 0
)
type "%~dp0next-output.txt"
"""
: """
#!/bin/sh
script_dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
printf '%s' "$*" > "$script_dir/last-arguments.txt"
if [ "$1" = "--version" ]; then
cat "$script_dir/version-output.txt"
exit 0
fi
cat "$script_dir/next-output.txt"
""";
}
}

private sealed class TestDocument(string id) : IDocument
{
public string Id { get; } = id;
}

private sealed class TestItem(string id) : IItem
{
public string Id { get; } = id;
}

private sealed class TestVault(string id, string name = "Test Vault") : IVault
{
public string Id { get; } = id;

public string Name { get; } = name;

public void Deconstruct(out string vaultId, out string vaultName)
{
vaultId = Id;
vaultName = Name;
}

public bool Equals(IResult<IVault>? other) => other is not null && string.Equals(Id, other.Id, StringComparison.OrdinalIgnoreCase);

public override bool Equals(object? obj) => obj is IResult<IVault> other && Equals(other);

public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(Id);

public int CompareTo(IResult<IVault>? other) => other is null ? 1 : string.Compare(Id, other.Id, StringComparison.Ordinal);

public int CompareTo(object? obj)
{
if (obj is null)
return 1;
if (obj is IResult<IVault> other)
return CompareTo(other);
throw new ArgumentException($"Object must be of type {nameof(IResult<IVault>)}.", nameof(obj));
}
}
}
23 changes: 22 additions & 1 deletion OnePassword.NET.Tests/TestItems.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public class TestItems : TestsBase
private const string FinalUsername = "Test Username";
private const FieldType FinalType = FieldType.Concealed;
private const string FinalValue = "Test Value";
private const string AddedField = "Added Field";
private const string AddedValue = "Added Value";
private const string Tag1 = "Tag1";
private const string Tag2 = "Tag2";

Expand Down Expand Up @@ -99,6 +101,24 @@ public void EditItem()

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

Run(RunType.Test, () =>
{
var item = OnePassword.GetItem(_initialItem, TestVault);
item.Fields.Add(new Field(AddedField, FieldType.String, AddedValue));

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

Assert.That(editedItem.Fields.FirstOrDefault(x => x.Label == AddedField)?.Value, Is.EqualTo(AddedValue));
});
}

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

[Test]
[Order(4)]
[Order(5)]
public void GetItem()
{
if (!RunLiveTests)
Expand All @@ -140,6 +160,7 @@ public void GetItem()
Assert.That(item.Created, Is.Not.EqualTo(default));
Assert.That(item.Fields.First(x => x.Label == "username").Value, Is.EqualTo(FinalUsername));
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);
});
});
Expand Down
Loading
Loading