Skip to content

Commit 24a2f34

Browse files
authored
Merge pull request #101 from jscarle/feature/fix-change-tracking
2 parents 31a00eb + 5279ff9 commit 24a2f34

5 files changed

Lines changed: 147 additions & 8 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ This repository contains `OnePassword.NET`, a .NET wrapper for the 1Password CLI
2727
- Do not read, search, or summarize generated documentation/site assets unless the user explicitly asks for them.
2828
- 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.
2929

30+
## Tests
31+
32+
- In this repository, skipped live tests usually mean the configured test account does not support the required operations for those scenarios.
33+
- When reporting test results, call out that capability limitation explicitly instead of treating those skips as product failures by default.
34+
3035
## API Abstraction
3136

3237
- Never expose or leak raw 1Password CLI responses through the public API unless the user explicitly asks for that exact behavior.

OnePassword.NET.Tests/OnePasswordManagerCommandTests.cs

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
using System.Runtime.InteropServices;
55
using OnePassword.Common;
66
using OnePassword.Documents;
7+
using OnePassword.Items;
8+
using OnePassword.Templates;
79
using OnePassword.Vaults;
810

911
namespace OnePassword;
@@ -211,6 +213,74 @@ public void UpdateExtractsCurrentPlatformExecutablePayload()
211213
});
212214
}
213215

216+
[Test]
217+
public void AcceptChangesOnItemClearsNestedUrlChanges()
218+
{
219+
var item = CreateTrackedItem("item-id", "Original Title");
220+
item.Urls.Add(new Url { Href = "https://initial.example.com" });
221+
222+
AcceptChanges(item);
223+
224+
Assert.That(IsTrackedChanged(item), Is.False);
225+
226+
item.Urls[0].Href = "https://updated.example.com";
227+
Assert.That(IsTrackedChanged(item), Is.True);
228+
229+
AcceptChanges(item);
230+
231+
Assert.That(IsTrackedChanged(item), Is.False);
232+
}
233+
234+
[Test]
235+
public void AcceptChangesOnFieldClearsTypeChanges()
236+
{
237+
var field = new Field("Environment", FieldType.String, "Production");
238+
239+
AcceptChanges(field);
240+
241+
Assert.That(IsTrackedChanged(field), Is.False);
242+
243+
field.Type = FieldType.Concealed;
244+
Assert.That(IsTrackedChanged(field), Is.True);
245+
246+
AcceptChanges(field);
247+
248+
Assert.That(IsTrackedChanged(field), Is.False);
249+
}
250+
251+
[Test]
252+
public void CreateItemFailureKeepsTemplateDirty()
253+
{
254+
using var fakeCli = new FakeCli(errorOutput: "[ERROR] create failed");
255+
var manager = fakeCli.CreateManager();
256+
var template = new Template
257+
{
258+
Title = "Original Title"
259+
};
260+
261+
AcceptChanges(template);
262+
263+
template.Title = "Updated Title";
264+
265+
Assert.Throws<InvalidOperationException>(() => manager.CreateItem(template, "vault-id"));
266+
Assert.That(IsTrackedChanged(template), Is.True);
267+
}
268+
269+
[Test]
270+
public void EditItemFailureKeepsItemDirty()
271+
{
272+
using var fakeCli = new FakeCli(errorOutput: "[ERROR] edit failed");
273+
var manager = fakeCli.CreateManager();
274+
var item = CreateTrackedItem("item-id", "Original Title");
275+
276+
AcceptChanges(item);
277+
278+
item.Title = "Updated Title";
279+
280+
Assert.Throws<InvalidOperationException>(() => manager.EditItem(item, "vault-id"));
281+
Assert.That(IsTrackedChanged(item), Is.True);
282+
}
283+
214284
[Test]
215285
public void ShareItemWithoutEmailsOmitsEmailsFlag()
216286
{
@@ -345,16 +415,18 @@ private sealed class FakeCli : IDisposable
345415
{
346416
private readonly string _argumentsPath;
347417
private readonly string _directoryPath;
418+
private readonly string _errorOutputPath;
348419
private readonly string _nextOutputPath;
349420
private readonly string _updateMessagePath;
350421
private readonly string _updatePayloadPath;
351422
private readonly string _updatedVersionOutputPath;
352423
private readonly string _versionOutputPath;
353424

354-
public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", string? updateVersionOutput = null)
425+
public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", string? updateVersionOutput = null, string? errorOutput = null)
355426
{
356427
_directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
357428
_argumentsPath = Path.Combine(_directoryPath, "last-arguments.txt");
429+
_errorOutputPath = Path.Combine(_directoryPath, "error-output.txt");
358430
_nextOutputPath = Path.Combine(_directoryPath, "next-output.txt");
359431
_updateMessagePath = Path.Combine(_directoryPath, "update-output.txt");
360432
_updatePayloadPath = Path.Combine(_directoryPath, "update-payload.zip");
@@ -364,6 +436,8 @@ public FakeCli(string versionOutput = "2.32.1\n", string nextOutput = "{}", stri
364436
Directory.CreateDirectory(_directoryPath);
365437
File.WriteAllText(_nextOutputPath, nextOutput);
366438
File.WriteAllText(_versionOutputPath, versionOutput);
439+
if (errorOutput is not null)
440+
File.WriteAllText(_errorOutputPath, errorOutput);
367441
if (updateVersionOutput is not null)
368442
{
369443
File.WriteAllText(_updateMessagePath, $"Version {updateVersionOutput.Trim()} is now available.");
@@ -435,6 +509,10 @@ @echo off
435509
type "%~dp0VERSION_OUTPUT_PLACEHOLDER"
436510
exit /b 0
437511
)
512+
if exist "%~dp0error-output.txt" (
513+
type "%~dp0error-output.txt" 1>&2
514+
exit /b 0
515+
)
438516
type "%~dp0next-output.txt"
439517
""".Replace("VERSION_OUTPUT_PLACEHOLDER", versionOutputFileName)
440518
: """
@@ -454,13 +532,65 @@ exit 0
454532
cat "$script_dir/VERSION_OUTPUT_PLACEHOLDER"
455533
exit 0
456534
fi
535+
if [ -f "$script_dir/error-output.txt" ]; then
536+
cat "$script_dir/error-output.txt" >&2
537+
exit 0
538+
fi
457539
cat "$script_dir/next-output.txt"
458540
""".Replace("VERSION_OUTPUT_PLACEHOLDER", versionOutputFileName);
459541
}
460542

461543
private static string PackagedExecutableName => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "op.exe" : "op";
462544
}
463545

546+
private static Item CreateTrackedItem(string itemId, string title)
547+
{
548+
var item = new Item
549+
{
550+
Title = title
551+
};
552+
SetNonPublicProperty(item, nameof(Item.Id), itemId);
553+
return item;
554+
}
555+
556+
private static void SetNonPublicProperty(object target, string propertyName, object? value)
557+
{
558+
var property = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
559+
?? throw new InvalidOperationException($"Could not find property '{propertyName}' on type {target.GetType().Name}.");
560+
property.SetValue(target, value);
561+
}
562+
563+
private static bool IsTrackedChanged(object tracked)
564+
{
565+
var interfaceType = GetTrackedInterface(tracked.GetType());
566+
var changedProperty = interfaceType.GetProperty("Changed", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
567+
?? throw new InvalidOperationException("Could not find ITracked.Changed.");
568+
var interfaceMap = tracked.GetType().GetInterfaceMap(interfaceType);
569+
var methodIndex = Array.IndexOf(interfaceMap.InterfaceMethods, changedProperty.GetMethod);
570+
return methodIndex >= 0
571+
? (bool)(interfaceMap.TargetMethods[methodIndex].Invoke(tracked, null) ?? false)
572+
: throw new InvalidOperationException("Could not resolve the ITracked.Changed implementation.");
573+
}
574+
575+
private static void AcceptChanges(object tracked)
576+
{
577+
var interfaceType = GetTrackedInterface(tracked.GetType());
578+
var acceptChangesMethod = interfaceType.GetMethod("AcceptChanges", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
579+
?? throw new InvalidOperationException("Could not find ITracked.AcceptChanges.");
580+
var interfaceMap = tracked.GetType().GetInterfaceMap(interfaceType);
581+
var methodIndex = Array.IndexOf(interfaceMap.InterfaceMethods, acceptChangesMethod);
582+
if (methodIndex < 0)
583+
throw new InvalidOperationException("Could not resolve the ITracked.AcceptChanges implementation.");
584+
585+
interfaceMap.TargetMethods[methodIndex].Invoke(tracked, null);
586+
}
587+
588+
private static Type GetTrackedInterface(Type type)
589+
{
590+
return type.GetInterfaces().FirstOrDefault(x => x.FullName == "OnePassword.Common.ITracked")
591+
?? throw new InvalidOperationException($"Type {type.Name} does not implement OnePassword.Common.ITracked.");
592+
}
593+
464594
private sealed class TestDocument(string id) : IDocument
465595
{
466596
public string Id { get; } = id;

OnePassword.NET/Common/TrackedList.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ public T this[int index]
9393
void ITracked.AcceptChanges()
9494
{
9595
_changed = false;
96+
foreach (var item in _list)
97+
{
98+
if (item is ITracked tracked)
99+
tracked.AcceptChanges();
100+
}
96101
_initialList = new List<T>(_list);
97102
}
98103

@@ -215,4 +220,4 @@ void IList<T>.RemoveAt(int index)
215220
_list.RemoveAt(index);
216221
_changed = true;
217222
}
218-
}
223+
}

OnePassword.NET/Items/Field.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public Field(string label, FieldType type, string value, Section? section = null
9999
/// <inheritdoc />
100100
void ITracked.AcceptChanges()
101101
{
102+
TypeChanged = false;
102103
ValueChanged = false;
103104
}
104105
}

OnePassword.NET/OnePasswordManager.Items.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,9 @@ public Item CreateItem(Template template, string vaultId)
130130
command += $" {QuoteArgument(assignmentStatement)}";
131131

132132
var json = SerializeTemplateForItemCommand(template);
133+
var createdItem = Op(JsonContext.Default.Item, command, json);
133134
((ITracked)template).AcceptChanges();
134-
if (template.TitleChanged)
135-
command += $" --title \"{template.Title}\"";
136-
if (((ITracked)template.Tags).Changed)
137-
command += $" --tags \"{template.Tags.ToCommaSeparated()}\"";
138-
return Op(JsonContext.Default.Item, command, json);
135+
return createdItem;
139136
}
140137

141138
/// <inheritdoc />
@@ -172,8 +169,9 @@ public Item EditItem(Item item, string vaultId)
172169
var changedUrl = item.Urls.FirstOrDefault(url => url.Primary && ((ITracked)url).Changed);
173170
command += $" --url \"{changedUrl}\"";
174171
}
172+
var editedItem = Op(JsonContext.Default.Item, command, json);
175173
((ITracked)item).AcceptChanges();
176-
return Op(JsonContext.Default.Item, command, json);
174+
return editedItem;
177175
}
178176

179177
/// <inheritdoc />

0 commit comments

Comments
 (0)